Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a86c23565 | |||
| ea866377bc | |||
| a7b984ca84 | |||
| 1ba85e129e | |||
| c75bf2ad71 | |||
| d91f39d087 | |||
| f39bf049a0 | |||
| 5b2648d526 | |||
| a8ecf65180 | |||
| 4d96524adb | |||
| 1d5006fd7e | |||
| 406a041ce9 | |||
| d7233d817c | |||
| 92aaf63c12 | |||
| ef09a3c84d | |||
| 463fbd8332 | |||
| 0cce4727e5 | |||
| 83427b4f6b | |||
| 4aa3d0134c | |||
| 0eb135249e | |||
| 1bada8d83e |
@@ -4,11 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Vaessl is an AI-powered integration bridge that accepts user text/image inputs, processes them through an LLM pipeline (via LiteLLM), and exports structured data to management systems (Homebox, WikiJS). The backend uses a provider pattern for extensibility, and the frontend is in early scaffolding stage.
|
Vaessl is an AI-powered integration bridge that accepts user text/image inputs, processes them through an LLM pipeline (via LiteLLM), and exports structured data to management systems (Homebox, WikiJS). The backend uses a provider pattern for extensibility. The frontend has a working connection management dashboard.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Backend (Spring Boot + Gradle, inside `backend/`)
|
### Backend (Spring Boot + Gradle, inside `backend/`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew build # compile and package
|
./gradlew build # compile and package
|
||||||
./gradlew test # run all tests
|
./gradlew test # run all tests
|
||||||
@@ -16,6 +17,7 @@ Vaessl is an AI-powered integration bridge that accepts user text/image inputs,
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (React + Vite, inside `frontend/`)
|
### Frontend (React + Vite, inside `frontend/`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # start dev server
|
npm run dev # start dev server
|
||||||
npm run build # TypeScript check + Vite build
|
npm run build # TypeScript check + Vite build
|
||||||
@@ -27,32 +29,58 @@ npm run test:ui # Vitest visual dashboard
|
|||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Copy `.env.local` (not committed) into `backend/` with:
|
Copy `.env.local` (not committed) into `backend/` with:
|
||||||
|
|
||||||
- `DB_URL`, `DB_TEST_URL`, `DB_USERNAME`, `DB_PASSWORD` — PostgreSQL (test container on port 5434)
|
- `DB_URL`, `DB_TEST_URL`, `DB_USERNAME`, `DB_PASSWORD` — PostgreSQL (test container on port 5434)
|
||||||
|
- `PG_DRIVER_CLASS_NAME` — PostgreSQL JDBC driver class
|
||||||
- `OPENAI_KEY`, `OPENAI_BASE_URL` — LiteLLM gateway (provider-agnostic, configured for gpt-4o-mini)
|
- `OPENAI_KEY`, `OPENAI_BASE_URL` — LiteLLM gateway (provider-agnostic, configured for gpt-4o-mini)
|
||||||
|
- `FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL` — allowed CORS origins for the backend
|
||||||
|
|
||||||
|
Frontend (optional, defaults to `/api`):
|
||||||
|
|
||||||
|
- `VITE_API_URL` — backend base URL used by `api/client.ts`
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Backend (`backend/src/main/java/com/vaessl/app/`)
|
### Backend (`backend/src/main/java/com/vaessl/app/`)
|
||||||
|
|
||||||
Three main modules:
|
Server context path is `/api`. Main endpoints:
|
||||||
|
|
||||||
|
- `POST /api/login` — authenticates a service, stores connection ID in session
|
||||||
|
- `GET /api/connections/status` — lists connected services for the current session
|
||||||
|
- `DELETE /api/connections/{serviceType}` — removes a service from the session; invalidates the session if no connections remain
|
||||||
|
|
||||||
|
Four main modules:
|
||||||
|
|
||||||
|
**`config/`**
|
||||||
|
|
||||||
|
- `CorsConfig`: env-driven allowed origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`); `allowCredentials(true)` is required for session cookies to work cross-origin
|
||||||
|
- `SessionConfig`: JDBC-backed Spring Session with a persistent cookie (`SameSite=Lax`, `HttpOnly`)
|
||||||
|
|
||||||
**`connection/`** — core business logic
|
**`connection/`** — core business logic
|
||||||
|
|
||||||
- `ConnectionProvider` interface: each integrated app (Homebox, WikiJS) implements `login()` and declares its `ServiceType`
|
- `ConnectionProvider` interface: each integrated app (Homebox, WikiJS) implements `login()` and declares its `ServiceType`
|
||||||
- `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType`
|
- `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType`
|
||||||
|
- `ConnectionController`: stores `{serviceType}_CONNECTION_ID` in `HttpSession` after login; reads session attributes to build status responses
|
||||||
- Entity (`Connection`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns
|
- Entity (`Connection`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns
|
||||||
- DTOs use `Map<String, Object>` for flexible cross-app credential/result exchange
|
|
||||||
|
|
||||||
**`dto/`** — `ConnectionRequest` / `ConnectionResponse`
|
**`dto/`** — `ConnectionRequest`, `LoginResult`, `AuthResponse`, `ConnectionStatusResponse`
|
||||||
|
|
||||||
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
|
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
|
||||||
|
|
||||||
### Frontend (`frontend/src/`)
|
### Frontend (`frontend/src/`)
|
||||||
|
|
||||||
React 19 + TypeScript + Tailwind CSS, Vite 8 build. Currently basic scaffolding; no significant business logic yet.
|
React 19 + TypeScript + SCSS, Vite 8 build.
|
||||||
|
|
||||||
|
- `api/client.ts` — typed `apiFetch` wrapper; always sends `credentials: 'include'` for session cookies; base URL from `VITE_API_URL` (defaults to `/api`)
|
||||||
|
- `api/connections.ts` — connection-specific API calls
|
||||||
|
- `types/connection.ts` — shared types: `LoginRequest`, `AuthResponse`, `ConnectionStatus`
|
||||||
|
- `components/Dashboard` — main view listing connected services
|
||||||
|
- `components/ConnectModal` — login form for adding a service connection
|
||||||
|
- `components/ServiceCard` — per-service status display
|
||||||
|
|
||||||
### Data & AI
|
### Data & AI
|
||||||
|
|
||||||
- PostgreSQL + pgvector (semantic search via embeddings)
|
- PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
|
||||||
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it
|
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it
|
||||||
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
|
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
java
|
java
|
||||||
id("org.springframework.boot") version "4.0.5"
|
id("org.springframework.boot") version "4.0.6"
|
||||||
id("io.spring.dependency-management") version "1.1.7"
|
id("io.spring.dependency-management") version "1.1.7"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,18 +8,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
@Configuration
|
@Configuration
|
||||||
public class CorsConfig implements WebMvcConfigurer {
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
@Value("${vaessl.frontend-local-url}")
|
@Value("${vaessl.allowed-origins}")
|
||||||
private String frontendLocalUrl;
|
private String[] allowedOrigins;
|
||||||
|
|
||||||
@Value("${vaessl.frontend-public-url}")
|
|
||||||
private String frontendPublicUrl;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**").allowedOrigins(allowedOrigins)
|
||||||
.allowedOrigins(frontendLocalUrl, frontendPublicUrl)
|
|
||||||
.allowedMethods("GET", "POST", "DELETE", "OPTIONS")
|
.allowedMethods("GET", "POST", "DELETE", "OPTIONS")
|
||||||
.allowedHeaders("Content-Type", "Accept")
|
.allowedHeaders("Content-Type", "Accept").allowCredentials(true);
|
||||||
.allowCredentials(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.vaessl.app.dto;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record AuthResponse(String serviceType, Instant expiresAt) {}
|
public record AuthResponse(String serviceType, Instant expiresAt) {
|
||||||
|
}
|
||||||
@@ -14,11 +14,6 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import com.vaessl.app.dto.AuthResponse;
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.dto.ConnectionStatusResponse;
|
|
||||||
import com.vaessl.app.dto.LoginResult;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -33,8 +28,7 @@ public class ConnectionController {
|
|||||||
private final ConnectionService connectionService;
|
private final ConnectionService connectionService;
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<AuthResponse> login(
|
public ResponseEntity<AuthResponse> login(@Valid @RequestBody ConnectionRequest request,
|
||||||
@Valid @RequestBody ConnectionRequest request,
|
|
||||||
HttpServletRequest httpReq) {
|
HttpServletRequest httpReq) {
|
||||||
|
|
||||||
LoginResult result = connectionService.login(request);
|
LoginResult result = connectionService.login(request);
|
||||||
@@ -58,21 +52,21 @@ public class ConnectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<ConnectionStatusResponse> statuses = new ArrayList<>();
|
List<ConnectionStatusResponse> statuses = new ArrayList<>();
|
||||||
Collections.list(session.getAttributeNames()).stream()
|
Collections.list(session.getAttributeNames()).stream().filter(k -> k.endsWith(SUFFIX))
|
||||||
.filter(k -> k.endsWith(SUFFIX))
|
|
||||||
.forEach(k -> {
|
.forEach(k -> {
|
||||||
String serviceType = k.replace(SUFFIX, "");
|
String serviceType = k.replace(SUFFIX, "");
|
||||||
Long id = (Long) session.getAttribute(k);
|
Long id = (Long) session.getAttribute(k);
|
||||||
ConnectionStatusResponse status = connectionService.getConnectionStatus(serviceType, id);
|
ConnectionStatusResponse status =
|
||||||
if (status != null) statuses.add(status);
|
connectionService.getConnectionStatus(serviceType, id);
|
||||||
|
if (status != null)
|
||||||
|
statuses.add(status);
|
||||||
});
|
});
|
||||||
|
|
||||||
return ResponseEntity.ok(statuses);
|
return ResponseEntity.ok(statuses);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/connections/{serviceType}")
|
@DeleteMapping("/connections/{serviceType}")
|
||||||
public ResponseEntity<Void> logout(
|
public ResponseEntity<Void> logout(@PathVariable("serviceType") String serviceType,
|
||||||
@PathVariable("serviceType") String serviceType,
|
|
||||||
HttpServletRequest httpReq) {
|
HttpServletRequest httpReq) {
|
||||||
|
|
||||||
HttpSession session = httpReq.getSession(false);
|
HttpSession session = httpReq.getSession(false);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import lombok.NoArgsConstructor;
|
|||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
|
@Table(name = "connections",
|
||||||
|
uniqueConstraints = {@UniqueConstraint(columnNames = {"appUrl", "username"})})
|
||||||
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||||
@DiscriminatorColumn(name = "service_type")
|
@DiscriminatorColumn(name = "service_type")
|
||||||
@Getter
|
@Getter
|
||||||
|
|||||||
@@ -2,15 +2,10 @@ package com.vaessl.app.connection;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
public interface ConnectionProvider extends ServiceProvider {
|
||||||
import com.vaessl.app.dto.ConnectionResponse;
|
|
||||||
|
|
||||||
public interface ConnectionProvider {
|
|
||||||
|
|
||||||
void checkCredentials(ConnectionRequest request);
|
void checkCredentials(ConnectionRequest request);
|
||||||
|
|
||||||
String getServiceType();
|
|
||||||
|
|
||||||
ConnectionResponse authenticate(ConnectionRequest request);
|
ConnectionResponse authenticate(ConnectionRequest request);
|
||||||
|
|
||||||
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
|
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
|
||||||
|
|||||||
+5
-8
@@ -1,15 +1,12 @@
|
|||||||
package com.vaessl.app.dto;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
public record ConnectionRequest(
|
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,
|
||||||
String username,
|
String username, String password, String apiKey,
|
||||||
String password,
|
|
||||||
String apiKey,
|
|
||||||
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
|
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
|
||||||
|
|
||||||
public ConnectionRequest {
|
public ConnectionRequest {
|
||||||
@@ -18,8 +15,8 @@ public record ConnectionRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ConnectionRequest(String appUrl, String serviceType, String username, String password,
|
public ConnectionRequest(String appUrl, String serviceType, String username,
|
||||||
Boolean stayLoggedIn) {
|
String password, Boolean stayLoggedIn) {
|
||||||
this(appUrl, serviceType, username, password, null, stayLoggedIn);
|
this(appUrl, serviceType, username, password, null, stayLoggedIn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-4
@@ -1,12 +1,13 @@
|
|||||||
package com.vaessl.app.dto;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
|
public record ConnectionResponse(String token, Instant expiresAt,
|
||||||
|
Map<String, Object> extraResponseData) {
|
||||||
|
|
||||||
public String getExtraVar(String key) {
|
public String getExtraVar(String key) {
|
||||||
if(extraResponseData == null) {
|
if (extraResponseData == null) {
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
Object value = extraResponseData.get(key);
|
Object value = extraResponseData.get(key);
|
||||||
@@ -7,10 +7,6 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.dto.ConnectionResponse;
|
|
||||||
import com.vaessl.app.dto.ConnectionStatusResponse;
|
|
||||||
import com.vaessl.app.dto.LoginResult;
|
|
||||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -54,13 +50,14 @@ public class ConnectionService {
|
|||||||
|
|
||||||
public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
|
public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
|
||||||
ConnectionEntity entity = cRepository.findById(connectionId).orElse(null);
|
ConnectionEntity entity = cRepository.findById(connectionId).orElse(null);
|
||||||
if (entity == null) return null;
|
if (entity == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
ConnectionProvider provider = providerRegistry.get(serviceType);
|
ConnectionProvider provider = providerRegistry.get(serviceType);
|
||||||
Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null;
|
Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null;
|
||||||
boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now());
|
boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now());
|
||||||
|
|
||||||
return new ConnectionStatusResponse(serviceType, entity.getAppUrl(),
|
return new ConnectionStatusResponse(serviceType, entity.getAppUrl(), entity.getUsername(),
|
||||||
entity.getUsername(), expiresAt, connected);
|
expiresAt, connected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record ConnectionStatusResponse(String serviceType, String appUrl, String username,
|
||||||
|
Instant expiresAt, boolean connected) {
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
package com.vaessl.app.connection;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
public enum Endpoint {
|
public enum Endpoint {
|
||||||
HOMEBOX_LOGIN("/api/v1/users/login"),
|
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS(
|
||||||
LOGIN("/login"),
|
"/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/items"), SEARCH("/search");
|
||||||
CONNECTION_STATUS("/connections/status");
|
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -9,20 +9,20 @@ import java.util.Map;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.client.RestClient;
|
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 com.vaessl.app.exception.EmptyCredentialsException;
|
||||||
|
import com.vaessl.app.exception.RemoteApiException;
|
||||||
|
|
||||||
import static com.vaessl.app.connection.Endpoint.*;
|
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;
|
||||||
|
|
||||||
private final ConnectionRepository cRepository;
|
private final ConnectionRepository cRepository;
|
||||||
|
|
||||||
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
|
public HomeboxConnectionProvider(RestClient.Builder restClientBuilder,
|
||||||
|
ConnectionRepository cRepository) {
|
||||||
this.restClientBuilder = restClientBuilder;
|
this.restClientBuilder = restClientBuilder;
|
||||||
this.cRepository = cRepository;
|
this.cRepository = cRepository;
|
||||||
}
|
}
|
||||||
@@ -50,27 +50,26 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ConnectionResponse authenticate(ConnectionRequest request) {
|
public ConnectionResponse authenticate(ConnectionRequest request) {
|
||||||
Map<String, Object> homeboxPayload = Map.of("username", request.username(),
|
Map<String, Object> homeboxPayload = Map.of("username", request.username(), "password",
|
||||||
"password", request.password(), "stayLoggedIn",
|
request.password(), "stayLoggedIn", request.stayLoggedIn());
|
||||||
request.stayLoggedIn());
|
|
||||||
|
|
||||||
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
|
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().post()
|
||||||
.build()
|
.uri(HOMEBOX_LOGIN.getValue()).body(homeboxPayload).retrieve()
|
||||||
.post()
|
|
||||||
.uri(HOMEBOX_LOGIN.getValue())
|
|
||||||
.body(homeboxPayload)
|
|
||||||
.retrieve()
|
|
||||||
.body(HomeboxLoginResponse.class);
|
.body(HomeboxLoginResponse.class);
|
||||||
|
|
||||||
if (hbResponse == null) {
|
if (hbResponse == null) {
|
||||||
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
|
throw new RemoteApiException(request.appUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> attachmentToken = new HashMap<>();
|
Map<String, Object> attachmentToken = new HashMap<>();
|
||||||
|
|
||||||
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
|
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
|
||||||
|
|
||||||
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
|
String hbRawToken = hbResponse.token();
|
||||||
|
|
||||||
|
String token = hbRawToken.startsWith("Bearer ") ? hbRawToken.substring(7) : hbRawToken;
|
||||||
|
|
||||||
|
return new ConnectionResponse(token, hbResponse.expiresAt(), attachmentToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -80,7 +79,8 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
|
public ConnectionEntity connectionToEntity(ConnectionRequest request,
|
||||||
|
ConnectionResponse response) {
|
||||||
return HomeboxEntity.from(request, response);
|
return HomeboxEntity.from(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,4 +105,5 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
|
|||||||
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
|
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,6 @@ package com.vaessl.app.connection;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.dto.ConnectionResponse;
|
|
||||||
|
|
||||||
import jakarta.persistence.DiscriminatorValue;
|
import jakarta.persistence.DiscriminatorValue;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.vaessl.app.dto;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record LoginResult(Long connectionId, Instant expiresAt) {}
|
public record LoginResult(Long connectionId, Instant expiresAt) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
|
public interface ServiceProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the service type key used to look up this provider in a registry, e.g.
|
||||||
|
* {@code "HOMEBOX"}.
|
||||||
|
*/
|
||||||
|
String getServiceType();
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ public enum ServiceType {
|
|||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
private ServiceType(String value){
|
private ServiceType(String value) {
|
||||||
this.value = value;
|
this.value = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.vaessl.app.dto;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
public record ConnectionStatusResponse(
|
|
||||||
String serviceType,
|
|
||||||
String appUrl,
|
|
||||||
String username,
|
|
||||||
Instant expiresAt,
|
|
||||||
boolean connected) {}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.vaessl.app.exception;
|
||||||
|
|
||||||
|
public class ConnectionNotFoundException extends RuntimeException {
|
||||||
|
}
|
||||||
@@ -5,11 +5,18 @@ import org.springframework.http.HttpStatus;
|
|||||||
import com.fasterxml.jackson.annotation.JsonValue;
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
|
||||||
public enum ErrorMessage {
|
public enum ErrorMessage {
|
||||||
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(
|
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST,
|
||||||
HttpStatus.UNAUTHORIZED, "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
|
"Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(HttpStatus.UNAUTHORIZED,
|
||||||
HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."), SERVER_ERROR_GENERAL(
|
"Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
|
||||||
"The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.NOT_FOUND,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
"No such service type.");
|
"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."), CONNECTION_NOT_FOUND(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"No active connection found for this service."), REMOTE_API_EMPTY_RESPONSE(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"Remote API returned empty response for ");
|
||||||
|
|
||||||
private final HttpStatus status;
|
private final HttpStatus status;
|
||||||
private final String message;
|
private final String message;
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ public class GlobalExceptionHandler {
|
|||||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
|
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
|
||||||
|
|
||||||
String defaultMessages = e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage)
|
String defaultMessages = e.getBindingResult().getFieldErrors().stream()
|
||||||
.collect(Collectors.joining(", "));
|
.map(FieldError::getDefaultMessage).collect(Collectors.joining(", "));
|
||||||
|
|
||||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
|
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
|
||||||
@@ -43,14 +43,14 @@ public class GlobalExceptionHandler {
|
|||||||
@ExceptionHandler(HttpServerErrorException.class)
|
@ExceptionHandler(HttpServerErrorException.class)
|
||||||
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
|
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
|
||||||
|
|
||||||
return ProblemDetail
|
return ProblemDetail.forStatusAndDetail(e.getStatusCode(),
|
||||||
.forStatusAndDetail(e.getStatusCode(),
|
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
|
||||||
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(WrongServiceTypeException.class)
|
@ExceptionHandler(WrongServiceTypeException.class)
|
||||||
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
|
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
|
||||||
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage());
|
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(),
|
||||||
|
WRONG_SERVICE_TYPE.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(EmptyCredentialsException.class)
|
@ExceptionHandler(EmptyCredentialsException.class)
|
||||||
@@ -58,4 +58,15 @@ public class GlobalExceptionHandler {
|
|||||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
|
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ConnectionNotFoundException.class)
|
||||||
|
public ProblemDetail handleConnectionNotFound(ConnectionNotFoundException e) {
|
||||||
|
return ProblemDetail.forStatusAndDetail(CONNECTION_NOT_FOUND.getStatus(), CONNECTION_NOT_FOUND.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(RemoteApiException.class)
|
||||||
|
public ProblemDetail handleRemoteApiException(RemoteApiException e) {
|
||||||
|
return ProblemDetail.forStatusAndDetail(REMOTE_API_EMPTY_RESPONSE.getStatus(),
|
||||||
|
REMOTE_API_EMPTY_RESPONSE.getMessage() + e.getAppUrl());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.vaessl.app.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RemoteApiException extends RuntimeException {
|
||||||
|
|
||||||
|
private final String appUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import com.vaessl.app.connection.ConnectionEntity;
|
||||||
|
import com.vaessl.app.connection.ConnectionRepository;
|
||||||
|
import com.vaessl.app.connection.HomeboxEntity;
|
||||||
|
import com.vaessl.app.exception.ConnectionNotFoundException;
|
||||||
|
import com.vaessl.app.exception.RemoteApiException;
|
||||||
|
|
||||||
|
import static com.vaessl.app.connection.Endpoint.*;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class HomeboxSearchProvider implements SearchProvider {
|
||||||
|
|
||||||
|
private final RestClient.Builder restClientBuilder;
|
||||||
|
|
||||||
|
private final ConnectionRepository cRepository;
|
||||||
|
|
||||||
|
public HomeboxSearchProvider(RestClient.Builder restClientBuilder,
|
||||||
|
ConnectionRepository cRepository) {
|
||||||
|
this.restClientBuilder = restClientBuilder;
|
||||||
|
this.cRepository = cRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getServiceType() {
|
||||||
|
return "HOMEBOX";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable) {
|
||||||
|
|
||||||
|
ConnectionEntity entity =
|
||||||
|
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||||
|
|
||||||
|
if (!(entity instanceof HomeboxEntity hbEntity)) {
|
||||||
|
throw new ConnectionNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeboxSearchResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().get()
|
||||||
|
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue())
|
||||||
|
.queryParam("q", request.query())
|
||||||
|
.queryParam("page", pageable.getPageNumber() + 1)
|
||||||
|
.queryParam("pageSize", pageable.getPageSize()).build())
|
||||||
|
.headers(h -> h.setBearerAuth(
|
||||||
|
hbEntity.getToken())).retrieve().body(HomeboxSearchResponse.class);
|
||||||
|
|
||||||
|
if (hbResponse == null) {
|
||||||
|
throw new RemoteApiException(request.appUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SearchResponse> items = hbResponse.items().stream().map(i -> {
|
||||||
|
String title = i.name();
|
||||||
|
String description = i.description();
|
||||||
|
Map<String, Object> extraSearchResponseData = Map.of("location", i.location());
|
||||||
|
return new SearchResponse(title, description, extraSearchResponseData);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return new PageImpl<>(items, pageable, hbResponse.total());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record HomeboxSearchResponse(int page, int pageSize, int total,
|
||||||
|
List<HomeboxItem> items) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record HomeboxItem(String name, String description, HomeboxLocation location) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record HomeboxLocation(String name, String description) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
|
||||||
|
public record PagedSearchResponse<T>(List<T> content, int page, int pageSize, long totalElements,
|
||||||
|
boolean first, boolean last, String sort) {
|
||||||
|
|
||||||
|
public static <T> PagedSearchResponse<T> from(Page<T> pageResult) {
|
||||||
|
return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(),
|
||||||
|
pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(),
|
||||||
|
pageResult.isLast(), pageResult.getSort().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.web.PageableDefault;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
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 jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SearchController {
|
||||||
|
|
||||||
|
private final SearchService searchService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a paged search against the requested service. Returns {@code 401 Unauthorized} if
|
||||||
|
* there is no active session.
|
||||||
|
*/
|
||||||
|
@PostMapping("/search")
|
||||||
|
public ResponseEntity<PagedSearchResponse<SearchResponse>> search(
|
||||||
|
@Valid @RequestBody SearchRequest request,
|
||||||
|
@PageableDefault(size = 20) Pageable pageable, HttpServletRequest httpReq) {
|
||||||
|
HttpSession session = httpReq.getSession(false);
|
||||||
|
if (session == null
|
||||||
|
|| session.getAttribute(request.serviceType() + "_CONNECTION_ID") == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<SearchResponse> result = searchService.search(request, pageable);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(PagedSearchResponse.from(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import com.vaessl.app.connection.ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implemented by any service that supports querying its remote API for items.
|
||||||
|
*/
|
||||||
|
public interface SearchProvider extends ServiceProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a search query against the remote service and returns matching results.
|
||||||
|
*
|
||||||
|
* @param request the search request containing the query string, app URL, and user credentials
|
||||||
|
* @return a list of Page<SearchResponse> items matching the query
|
||||||
|
*/
|
||||||
|
Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
|
||||||
|
@NotBlank String serviceType) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record SearchResponse(String title, String description, Map<String, Object> extraData) {
|
||||||
|
|
||||||
|
public String getExtra(String key) {
|
||||||
|
if (extraData == null) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
Object value = extraData.get(key);
|
||||||
|
|
||||||
|
return value != null ? String.valueOf(value) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SearchService {
|
||||||
|
|
||||||
|
private final Map<String, SearchProvider> providerRegistry;
|
||||||
|
|
||||||
|
public SearchService(List<SearchProvider> providers) {
|
||||||
|
this.providerRegistry = Map.copyOf(providers.stream()
|
||||||
|
.collect(Collectors.toMap(SearchProvider::getServiceType, p -> p)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches the paged search request to the provider registered for
|
||||||
|
* {@link SearchRequest#serviceType()}.
|
||||||
|
*
|
||||||
|
* @param request the search request
|
||||||
|
* @param pageable the Pageable interface
|
||||||
|
* @return results returned by the matching provider
|
||||||
|
* @throws WrongServiceTypeException if no provider is registered for the given service type
|
||||||
|
*/
|
||||||
|
public Page<SearchResponse> search(SearchRequest request, Pageable pageable) {
|
||||||
|
|
||||||
|
SearchProvider provider = providerRegistry.get(request.serviceType());
|
||||||
|
|
||||||
|
if (provider == null) {
|
||||||
|
throw new WrongServiceTypeException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.getSearchResults(request, pageable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,8 @@
|
|||||||
"description": "A description for 'spring.session.store-type'"
|
"description": "A description for 'spring.session.store-type'"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vaessl.frontend-local-url",
|
"name": "vaessl.allowed-origins",
|
||||||
"type": "java.lang.String",
|
"type": "java.lang.String",
|
||||||
"description": "A description for 'vaessl.frontend-local-url'"
|
"description": "Comma-separated list of allowed CORS origins"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vaessl.frontend-public-url",
|
|
||||||
"type": "java.lang.String",
|
|
||||||
"description": "A description for 'vaessl.frontend-public-url'"
|
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
@@ -13,7 +13,7 @@ spring:
|
|||||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: create-drop
|
ddl-auto: update
|
||||||
show-sql: true
|
show-sql: true
|
||||||
ai:
|
ai:
|
||||||
openai:
|
openai:
|
||||||
@@ -30,5 +30,4 @@ server:
|
|||||||
servlet:
|
servlet:
|
||||||
context-path: /api
|
context-path: /api
|
||||||
vaessl:
|
vaessl:
|
||||||
frontend-local-url: ${FRONTEND_LOCAL_URL}
|
allowed-origins: ${ALLOWED_ORIGINS}
|
||||||
frontend-public-url: ${FRONTEND_PUBLIC_URL}
|
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ class ApplicationTests {
|
|||||||
private DataSource dataSource;
|
private DataSource dataSource;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
void contextLoads() {}
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void connectionToTestDbWorks() throws SQLException {
|
void connectionToTestDbWorks() throws SQLException {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.vaessl.app;
|
||||||
|
|
||||||
|
public final class Mockdata {
|
||||||
|
|
||||||
|
private Mockdata() {}
|
||||||
|
|
||||||
|
public static final String MOCK_URL = "http://localhost:1234";
|
||||||
|
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
|
||||||
|
public static final String MOCK_USER = "user";
|
||||||
|
public static final String MOCK_PASS = "pw";
|
||||||
|
public static final String MOCK_TITLE = "title";
|
||||||
|
public static final String MOCK_DESCRIPTION = "desc";
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.vaessl.app.connection;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
|
import static com.vaessl.app.Mockdata.*;
|
||||||
|
|
||||||
import static com.vaessl.app.connection.Endpoint.*;
|
import static com.vaessl.app.connection.Endpoint.*;
|
||||||
import static com.vaessl.app.connection.ServiceType.*;
|
import static com.vaessl.app.connection.ServiceType.*;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
@@ -24,134 +26,129 @@ import org.springframework.test.web.servlet.MvcResult;
|
|||||||
@WireMockTest
|
@WireMockTest
|
||||||
class ConnectionControllerTest {
|
class ConnectionControllerTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
MockMvc mockMvc;
|
MockMvc mockMvc;
|
||||||
|
|
||||||
private static final String TEST_USER = "admin";
|
private static final String LOGIN_PATH = LOGIN.getValue();
|
||||||
private static final String TEST_PASS = "pw";
|
private static final String STATUS_PATH = CONNECTION_STATUS.getValue();
|
||||||
private static final String LOGIN_PATH = LOGIN.getValue();
|
private static final String LOGOUT_PATH = "/connections/HOMEBOX";
|
||||||
private static final String STATUS_PATH = CONNECTION_STATUS.getValue();
|
|
||||||
private static final String LOGOUT_PATH = "/connections/HOMEBOX";
|
|
||||||
|
|
||||||
private static final String VALID_HOMEBOX_RESPONSE = """
|
private static final String VALID_HOMEBOX_RESPONSE = """
|
||||||
{
|
{
|
||||||
"token": "fake-jwt-token",
|
"token": "fake-jwt-token",
|
||||||
"attachmentToken": "fake-attach",
|
"attachmentToken": "fake-attach",
|
||||||
"expiresAt": "2099-01-01T00:00:00Z"
|
"expiresAt": "2099-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String EXPIRED_HOMEBOX_RESPONSE = """
|
private static final String EXPIRED_HOMEBOX_RESPONSE = """
|
||||||
{
|
{
|
||||||
"token": "expired-token",
|
"token": "expired-token",
|
||||||
"attachmentToken": "fake-attach",
|
"attachmentToken": "fake-attach",
|
||||||
"expiresAt": "2000-01-01T00:00:00Z"
|
"expiresAt": "2000-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnEmptyListWhenNoActiveSession() throws Exception {
|
void shouldReturnEmptyListWhenNoActiveSession() throws Exception {
|
||||||
mockMvc.perform(get(STATUS_PATH))
|
mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
.andExpect(content().json("[]"));
|
||||||
.andExpect(content().json("[]"));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm) throws Exception {
|
void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm)
|
||||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
throws Exception {
|
||||||
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||||
|
|
||||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
MvcResult loginResult = mockMvc
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(connectionRequestBody(wm)))
|
.content(connectionRequestBody(wm)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk()).andReturn();
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||||
|
|
||||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie))
|
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
.andExpect(jsonPath("$.length()").value(1))
|
||||||
.andExpect(jsonPath("$.length()").value(1))
|
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
||||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
.andExpect(jsonPath("$[0].username").value(MOCK_USER))
|
||||||
.andExpect(jsonPath("$[0].username").value(TEST_USER))
|
.andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl()))
|
||||||
.andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl()))
|
.andExpect(jsonPath("$[0].connected").value(true));
|
||||||
.andExpect(jsonPath("$[0].connected").value(true));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm) throws Exception {
|
void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm)
|
||||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE)));
|
throws Exception {
|
||||||
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE)));
|
||||||
|
|
||||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
MvcResult loginResult = mockMvc
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(connectionRequestBody(wm)))
|
.content(connectionRequestBody(wm)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk()).andReturn();
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||||
|
|
||||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie))
|
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
||||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
.andExpect(jsonPath("$[0].connected").value(false))
|
||||||
.andExpect(jsonPath("$[0].connected").value(false))
|
.andExpect(jsonPath("$[0].expiresAt")
|
||||||
.andExpect(jsonPath("$[0].expiresAt").value("2000-01-01T00:00:00Z"));
|
.value("2000-01-01T00:00:00Z"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception {
|
void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||||
|
|
||||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
MvcResult loginResult = mockMvc
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(connectionRequestBody(wm)))
|
.content(connectionRequestBody(wm)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk()).andReturn();
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||||
|
|
||||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception {
|
void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||||
|
|
||||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
MvcResult loginResult = mockMvc
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(connectionRequestBody(wm)))
|
.content(connectionRequestBody(wm)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk()).andReturn();
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||||
|
|
||||||
// Verify connected before logout
|
// Verify connected before logout
|
||||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie))
|
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
.andExpect(jsonPath("$.length()").value(1));
|
||||||
.andExpect(jsonPath("$.length()").value(1));
|
|
||||||
|
|
||||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
// A new request (no session cookie, as in a fresh browser) returns no connections
|
// A new request (no session cookie, as in a fresh browser) returns no connections
|
||||||
mockMvc.perform(get(STATUS_PATH))
|
mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
.andExpect(content().json("[]"));
|
||||||
.andExpect(content().json("[]"));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception {
|
void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception {
|
||||||
mockMvc.perform(delete(LOGOUT_PATH))
|
mockMvc.perform(delete(LOGOUT_PATH)).andExpect(status().isNoContent());
|
||||||
.andExpect(status().isNoContent());
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||||
return """
|
return """
|
||||||
{
|
{
|
||||||
"appUrl": "%s",
|
"appUrl": "%s",
|
||||||
"serviceType": "HOMEBOX",
|
"serviceType": "HOMEBOX",
|
||||||
"username": "%s",
|
"username": "%s",
|
||||||
"password": "%s"
|
"password": "%s"
|
||||||
}
|
}
|
||||||
""".formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS);
|
""".formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.vaessl.app.connection;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
|
import static com.vaessl.app.Mockdata.*;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
@@ -15,11 +16,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||||
|
|
||||||
import static com.vaessl.app.connection.Mockdata.*;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class ConnectionServiceTest {
|
class ConnectionServiceTest {
|
||||||
|
|
||||||
@@ -38,13 +36,14 @@ class ConnectionServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
|
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
|
||||||
ConnectionRequest request = new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
|
ConnectionRequest request =
|
||||||
|
new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
|
||||||
|
|
||||||
doThrow(new EmptyCredentialsException(List.of("username")))
|
doThrow(new EmptyCredentialsException(List.of("username"))).when(mockProvider)
|
||||||
.when(mockProvider).checkCredentials(request);
|
.checkCredentials(request);
|
||||||
|
|
||||||
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
|
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
|
||||||
|
|
||||||
verify(mockProvider, never()).authenticate(any());
|
verify(mockProvider, never()).authenticate(any());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-5
@@ -4,15 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||||
|
import static com.vaessl.app.Mockdata.*;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static com.vaessl.app.connection.Mockdata.*;
|
|
||||||
|
|
||||||
class HomeBoxConnectionProviderTest {
|
class HomeboxConnectionProviderTest {
|
||||||
|
|
||||||
private final HomeBoxConnectionProvider provider = new HomeBoxConnectionProvider(null, null);
|
private final HomeboxConnectionProvider provider = new HomeboxConnectionProvider(null, null);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
|
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.vaessl.app.connection;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
|
import static com.vaessl.app.Mockdata.*;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.resttestclient.TestRestTemplate;
|
import org.springframework.boot.resttestclient.TestRestTemplate;
|
||||||
@@ -14,7 +15,6 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
|||||||
|
|
||||||
import com.jayway.jsonpath.DocumentContext;
|
import com.jayway.jsonpath.DocumentContext;
|
||||||
import com.jayway.jsonpath.JsonPath;
|
import com.jayway.jsonpath.JsonPath;
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -42,9 +42,6 @@ class HomeboxIntegrationTest {
|
|||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String TEST_USER = "admin";
|
|
||||||
private static final String TEST_PASS = "pw";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns Token and status code OK when login is successful.
|
* Returns Token and status code OK when login is successful.
|
||||||
*
|
*
|
||||||
@@ -53,11 +50,10 @@ class HomeboxIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
|
void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
|
||||||
|
|
||||||
stubFor(post(HOMEBOX_LOGIN.getValue())
|
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse)));
|
||||||
.willReturn(okJson(okJsonHomeboxResponse)));
|
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
ResponseEntity<String> response =
|
||||||
String.class);
|
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
|
||||||
|
|
||||||
DocumentContext documentContext = JsonPath.parse(response.getBody());
|
DocumentContext documentContext = JsonPath.parse(response.getBody());
|
||||||
|
|
||||||
@@ -79,8 +75,8 @@ class HomeboxIntegrationTest {
|
|||||||
|
|
||||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
|
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
ResponseEntity<String> response =
|
||||||
String.class);
|
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
||||||
@@ -96,8 +92,8 @@ class HomeboxIntegrationTest {
|
|||||||
|
|
||||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
|
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
ResponseEntity<String> response =
|
||||||
String.class);
|
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(SERVER_ERROR_GENERAL.getMessage());
|
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
|
||||||
@@ -113,7 +109,8 @@ class HomeboxIntegrationTest {
|
|||||||
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
|
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
|
||||||
|
|
||||||
ConnectionRequest badRequest = connectionRequest(wm);
|
ConnectionRequest badRequest = connectionRequest(wm);
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
|
ResponseEntity<String> response =
|
||||||
|
restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
||||||
@@ -127,34 +124,30 @@ class HomeboxIntegrationTest {
|
|||||||
|
|
||||||
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
|
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), 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(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test the exception when there is an input for serviceType but it's
|
* Test the exception when there is an input for serviceType but it's unsupported.
|
||||||
* unsupported.
|
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) {
|
void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) {
|
||||||
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(
|
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(wm.getHttpBaseUrl(),
|
||||||
wm.getHttpBaseUrl(),
|
"wrong-service-type", MOCK_USER, MOCK_PASS, false);
|
||||||
"wrong-service-type",
|
|
||||||
TEST_USER, TEST_PASS,
|
|
||||||
false);
|
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq,
|
ResponseEntity<String> response =
|
||||||
String.class);
|
restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage());
|
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests the succesfull persistance of Homebox credential response to the
|
* Tests the succesfull persistance of Homebox credential response to the database.
|
||||||
* database.
|
|
||||||
*
|
*
|
||||||
* @param wm the WiremockRuntimeInfo object
|
* @param wm the WiremockRuntimeInfo object
|
||||||
*/
|
*/
|
||||||
@@ -166,10 +159,12 @@ class HomeboxIntegrationTest {
|
|||||||
|
|
||||||
ConnectionRequest request = connectionRequest(wm);
|
ConnectionRequest request = connectionRequest(wm);
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
|
ResponseEntity<String> response =
|
||||||
|
restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
|
||||||
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
ConnectionEntity dbEntry =
|
||||||
|
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||||
|
|
||||||
assertThat(dbEntry).isNotNull();
|
assertThat(dbEntry).isNotNull();
|
||||||
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
|
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
|
||||||
@@ -184,26 +179,24 @@ class HomeboxIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
|
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
|
||||||
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER,
|
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(),
|
||||||
null,
|
HOMEBOX.getValue(), MOCK_USER, null, false);
|
||||||
false);
|
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials,
|
ResponseEntity<String> response =
|
||||||
String.class);
|
restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a valid connection request with a mock Api through
|
* Creates a valid connection request with a mock Api through WireMockRuntimeInfo.
|
||||||
* WireMockRuntimeInfo.
|
|
||||||
*
|
*
|
||||||
* @param wm the WiremockRuntimeInfo object
|
* @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.getValue(), TEST_USER, TEST_PASS,
|
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), MOCK_USER, MOCK_PASS,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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,35 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import static com.vaessl.app.Mockdata.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import com.vaessl.app.connection.ConnectionRepository;
|
||||||
|
import com.vaessl.app.exception.ConnectionNotFoundException;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class HomeboxSearchProviderTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ConnectionRepository mockRepo;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private HomeboxSearchProvider provider;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnConnectionNotFoundException() {
|
||||||
|
|
||||||
|
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
|
||||||
|
|
||||||
|
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "test query", "HOMEBOX");
|
||||||
|
Pageable pageable = PageRequest.of(0, 10);
|
||||||
|
assertThrows(ConnectionNotFoundException.class,
|
||||||
|
() -> provider.getSearchResults(request, pageable));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import static com.vaessl.app.Mockdata.MOCK_PASS;
|
||||||
|
import static com.vaessl.app.Mockdata.MOCK_USER;
|
||||||
|
import static com.vaessl.app.connection.Endpoint.*;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||||
|
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||||
|
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@WireMockTest
|
||||||
|
class SearchControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
MockMvc mockMvc;
|
||||||
|
|
||||||
|
private static final String QUERY_ALL_ITEMS = HOMEBOX_QUERY_ALL_ITEMS.getValue();
|
||||||
|
|
||||||
|
private static final String LOGIN_PATH = LOGIN.getValue();
|
||||||
|
|
||||||
|
private static final String SEARCH_REQUEST = SEARCH.getValue();
|
||||||
|
|
||||||
|
private static final String VALID_HOMEBOX_LOGIN_RESPONSE = """
|
||||||
|
{
|
||||||
|
"token": "fake-bearer-token",
|
||||||
|
"attachmentToken": "fake-attach-token",
|
||||||
|
"expiresAt": "2099-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE = """
|
||||||
|
{
|
||||||
|
"page": -1,
|
||||||
|
"pageSize": -1,
|
||||||
|
"total": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "c643e7f9-93d0-4b5f-ae4d-e1c2d90389e0",
|
||||||
|
"assetId": "000-001",
|
||||||
|
"name": "MacBook Pro A1398",
|
||||||
|
"description": "Running Linux (Fedora)",
|
||||||
|
"quantity": 1,
|
||||||
|
"insured": false,
|
||||||
|
"archived": false,
|
||||||
|
"createdAt": "2026-05-13T19:52:20.016176Z",
|
||||||
|
"updatedAt": "2026-05-14T12:39:11.836403Z",
|
||||||
|
"purchasePrice": 0,
|
||||||
|
"location": {
|
||||||
|
"id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636",
|
||||||
|
"name": "Server Schrank Ikea weiß",
|
||||||
|
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
|
||||||
|
"createdAt": "2026-05-13T19:55:55.817576Z",
|
||||||
|
"updatedAt": "2026-05-14T12:37:24.396651Z"
|
||||||
|
},
|
||||||
|
"tags": [],
|
||||||
|
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
|
||||||
|
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
|
||||||
|
"soldTime": "0001-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnListOfQueriedHomeboxItems(WireMockRuntimeInfo wm) throws Exception {
|
||||||
|
|
||||||
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
|
||||||
|
|
||||||
|
WireMock.stubFor(WireMock.get(WireMock.urlPathEqualTo(QUERY_ALL_ITEMS))
|
||||||
|
.willReturn(WireMock.okJson(VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE)));
|
||||||
|
|
||||||
|
MvcResult loginResult =
|
||||||
|
mockMvc.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
|
||||||
|
|
||||||
|
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||||
|
|
||||||
|
mockMvc.perform(post(SEARCH_REQUEST).cookie(sessionCookie)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(searchRequestBody(wm, "HOMEBOX")))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398"))
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(1))
|
||||||
|
.andExpect(jsonPath("$.content[0].extraData.location.name")
|
||||||
|
.value("Server Schrank Ikea weiß"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnUnauthorizedWhenNoSession() throws Exception {
|
||||||
|
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON).content("""
|
||||||
|
{
|
||||||
|
"appUrl": "http://irrelevant",
|
||||||
|
"query": "Item",
|
||||||
|
"serviceType": "HOMEBOX",
|
||||||
|
"username": "irrelevant"
|
||||||
|
}
|
||||||
|
""")).andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnUnauthorizedWhenSessionHasNoConnectionForRequestedServiceType(
|
||||||
|
WireMockRuntimeInfo wm) throws Exception {
|
||||||
|
|
||||||
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
|
||||||
|
|
||||||
|
MvcResult loginResult =
|
||||||
|
mockMvc.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
|
||||||
|
|
||||||
|
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
post(SEARCH_REQUEST).cookie(sessionCookie).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(searchRequestBody(wm, "OTHER_SERVICETYPE")))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"appUrl": "%s",
|
||||||
|
"query": "Item",
|
||||||
|
"serviceType": "%s",
|
||||||
|
"username": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(wm.getHttpBaseUrl(), serviceType, MOCK_USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"appUrl": "%s",
|
||||||
|
"serviceType": "HOMEBOX",
|
||||||
|
"username": "%s",
|
||||||
|
"password": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import static com.vaessl.app.Mockdata.*;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class SearchResponseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnNullWhenExtraDataIsNull() {
|
||||||
|
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, null);
|
||||||
|
|
||||||
|
assertThat(response.getExtra(null)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnNullWhenExtraDataKeyIsMissing() {
|
||||||
|
|
||||||
|
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||||
|
|
||||||
|
assertThat(response.getExtra("missing")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnExtraDataValue() {
|
||||||
|
|
||||||
|
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||||
|
|
||||||
|
assertThat(response.getExtra("key")).contains("value");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
**vaessl: Claude Code feasability**
|
**vaessl: Claude Code feasability**
|
||||||
|
|
||||||
This is a showcase of how useful Claude Code can be for an app such as vaessl. There were very little steps to do to achieve an entire feature. This was achieved with a standard paid plan over 2 days. I never hit the limits and I must have used around 300k tokens likely a little more. Not only was I able to setup Java classes and React componentes. Claude Code pretty much nailed the CSS for the app. After minor adjustments for styling, CORS configurations, solving SonarQube warnings and generating additional integration tests I was good to commit the changes. The following documentation was also generated via CC.
|
This is a showcase of how useful Claude Code can be for an app such as Vaessl. There were very little steps to do to achieve an entire feature. This was achieved with a standard paid plan over 2 days. I never hit the limits and I must have used around 300k tokens likely a little more. Not only was I able to setup Java classes and React components. Claude Code pretty much nailed the CSS for the app. After minor adjustments for styling, CORS configurations, solving SonarQube warnings and generating additional integration tests I was good to commit the changes. The following documentation was also generated via CC.
|
||||||
|
|
||||||
The entire commit can be reviewed under following hash 43bbcece7a901e94021e10bca8b227c8ba285ac2.
|
The entire commit can be reviewed under following hash 43bbcece7a901e94021e10bca8b227c8ba285ac2.
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ The entire commit can be reviewed under following hash 43bbcece7a901e94021e10bca
|
|||||||
- DELETE /connections/{serviceType} — removes the specific key from the session. If no connections remain, the session is invalidated entirely.
|
- DELETE /connections/{serviceType} — removes the specific key from the session. If no connections remain, the session is invalidated entirely.
|
||||||
|
|
||||||
---
|
---
|
||||||
**connection/HomeBoxConnectionProvider.java (modified)**
|
**connection/HomeboxConnectionProvider.java (modified)**
|
||||||
|
|
||||||
Implements the new interface methods:
|
Implements the new interface methods:
|
||||||
- checkCredentials validates that username and password are present before touching the network.
|
- checkCredentials validates that username and password are present before touching the network.
|
||||||
|
|||||||
@@ -1,168 +1,121 @@
|
|||||||
**vaessl: Login Architecture**
|
**vaessl: Login Architecture**
|
||||||
|
|
||||||
# Backend
|
# 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.
|
|
||||||
|
The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance are used to keep refactorings to a minimum. The first app to bridge is Homebox, which uses a classic username/password and Bearer token login process. The second hypothetical app, WikiJS, uses a user-generated API key. The abstractions cater to both authentication methods.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The server context path is `/api`. The three endpoints that drive the connection flow:
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `POST` | `/api/login` | Authenticate a service; stores connection ID in session |
|
||||||
|
| `GET` | `/api/connections/status` | List connected services for the current session |
|
||||||
|
| `DELETE` | `/api/connections/{serviceType}` | Remove a service from the session; invalidates the session if no connections remain |
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
Authentication state is held server-side in a JDBC-backed Spring Session (stored in the same PostgreSQL database). After a successful login the controller stores `{serviceType}_CONNECTION_ID` in the `HttpSession`. The session cookie is `HttpOnly`, `SameSite=Lax`, and set to max age.
|
||||||
|
|
||||||
|
`CorsConfig` allows credentials from the two configured origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`). `allowCredentials(true)` is required for the session cookie to travel cross-origin.
|
||||||
|
|
||||||
## Single Table Inheritance
|
## 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.
|
Database entities use Single Table Inheritance — one `connections` table with a `service_type` discriminator column and all app-specific nullable columns. A unique constraint on `(appUrl, username)` prevents duplicate entries per user per instance.
|
||||||
The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy:
|
|
||||||
|
|
||||||
***ConnectionEntitiy.java***
|
***ConnectionEntity.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;
|
|
||||||
|
|
||||||
|
```java
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "connections")
|
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
|
||||||
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||||
@DiscriminatorColumn(name = "service_type")
|
@DiscriminatorColumn(name = "service_type")
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public abstract class ConnectionEntity {
|
public abstract class ConnectionEntity {
|
||||||
@Id
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
private Long id;
|
||||||
Long id;
|
|
||||||
|
|
||||||
private String appUrl;
|
private String appUrl;
|
||||||
|
private String username;
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
***HomeboxEntitiy.java***
|
***HomeboxEntity.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;
|
|
||||||
|
|
||||||
|
```java
|
||||||
@Entity
|
@Entity
|
||||||
@DiscriminatorValue("HOMEBOX")
|
@DiscriminatorValue("HOMEBOX")
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class HomeboxEntity extends ConnectionEntity {
|
public class HomeboxEntity extends ConnectionEntity {
|
||||||
|
|
||||||
private String token;
|
private String token;
|
||||||
private String attachmentToken;
|
private String attachmentToken;
|
||||||
private Instant expiresAt;
|
private Instant expiresAt;
|
||||||
|
|
||||||
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
|
|
||||||
|
|
||||||
|
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
|
||||||
HomeboxEntity he = new HomeboxEntity();
|
HomeboxEntity he = new HomeboxEntity();
|
||||||
|
|
||||||
he.setAppUrl(request.appUrl());
|
he.setAppUrl(request.appUrl());
|
||||||
|
he.setUsername(request.username());
|
||||||
he.setToken(response.token());
|
he.setToken(response.token());
|
||||||
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
|
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
|
||||||
he.setExpiresAt(response.expiresAt());
|
he.setExpiresAt(response.expiresAt());
|
||||||
|
|
||||||
return he;
|
return he;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## The Provider Pattern (Logic Layer)
|
## 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.
|
Each integrated app implements `ConnectionProvider`. This separates how an app authenticates (the specific) from what the system does with that result (the general).
|
||||||
|
|
||||||
***ConnectionProvider.java***
|
***ConnectionProvider.java***
|
||||||
```
|
|
||||||
package com.vaessl.app.connection;
|
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.dto.ConnectionResponse;
|
|
||||||
|
|
||||||
|
```java
|
||||||
public interface ConnectionProvider {
|
public interface ConnectionProvider {
|
||||||
|
|
||||||
String getServiceType();
|
String getServiceType();
|
||||||
|
|
||||||
|
void checkCredentials(ConnectionRequest request);
|
||||||
|
|
||||||
ConnectionResponse authenticate(ConnectionRequest request);
|
ConnectionResponse authenticate(ConnectionRequest request);
|
||||||
|
|
||||||
|
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
|
||||||
|
|
||||||
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
|
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
|
||||||
|
|
||||||
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
|
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
|
||||||
}
|
|
||||||
|
|
||||||
```
|
default Instant getTokenExpiry(ConnectionEntity entity) {
|
||||||
***HomeboxConnectionProvider.java***
|
return null;
|
||||||
```
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `checkCredentials` — validates that required fields are present before attempting the remote call (throws `EmptyCredentialsException` with a list of missing fields)
|
||||||
|
- `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update
|
||||||
|
- `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag
|
||||||
|
|
||||||
|
***HomeboxConnectionProvider.java***
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Component
|
||||||
|
public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getServiceType() {
|
public String getServiceType() { return "HOMEBOX"; }
|
||||||
return "HOMEBOX";
|
|
||||||
|
@Override
|
||||||
|
public void checkCredentials(ConnectionRequest request) {
|
||||||
|
// throws EmptyCredentialsException if username or password is null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ConnectionResponse authenticate(ConnectionRequest request) {
|
public ConnectionResponse authenticate(ConnectionRequest request) {
|
||||||
Map<String, Object> homeboxPayload = Map.of("username", request.credentials().get("username"),
|
// POST to Homebox /api/v1/users/login, returns token + attachmentToken + expiresAt
|
||||||
"password", request.credentials().get("password"), "stayLoggedIn",
|
}
|
||||||
request.stayLoggedIn());
|
|
||||||
|
|
||||||
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
|
@Override
|
||||||
.build()
|
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
|
||||||
.post()
|
return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||||
.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
|
@Override
|
||||||
@@ -172,131 +125,53 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
|
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
|
||||||
|
// casts to HomeboxEntity and updates token, attachmentToken, expiresAt
|
||||||
|
|
||||||
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) {
|
@Override
|
||||||
|
public Instant getTokenExpiry(ConnectionEntity entity) {
|
||||||
|
return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- 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***
|
***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;
|
|
||||||
|
|
||||||
|
```java
|
||||||
@Service
|
@Service
|
||||||
public class ConnectionService {
|
public class ConnectionService {
|
||||||
|
|
||||||
private final Map<String, ConnectionProvider> providerRegistry;
|
// auto-discovers all ConnectionProvider beans into a serviceType → provider map
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
public LoginResult login(ConnectionRequest request) {
|
||||||
|
provider.checkCredentials(request);
|
||||||
ConnectionResponse response = provider.authenticate(request);
|
ConnectionResponse response = provider.authenticate(request);
|
||||||
|
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
|
||||||
|
// insert or update, then return LoginResult(connectionId, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl());
|
public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
|
||||||
|
// looks up entity by id, calls provider.getTokenExpiry() to compute connected flag
|
||||||
if (existing != null) {
|
|
||||||
provider.updateToRepository(existing, response);
|
|
||||||
} else {
|
|
||||||
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
|
|
||||||
cRepository.save(newEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Generic Data Exchange (The DTOs)
|
## 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.
|
### Request / Internal
|
||||||
|
|
||||||
***ConnectionRequest.java***
|
- **`ConnectionRequest`** — fields: `appUrl`, `serviceType`, `username`, `password`, `apiKey`, `stayLoggedIn`. Typed fields (not a credentials map) let providers validate exactly what they need via `checkCredentials()`. `apiKey` covers future API-key-only providers (e.g. WikiJS).
|
||||||
|
|
||||||
```
|
- **`ConnectionResponse`** — internal DTO between provider and service: `token`, `expiresAt`, `Map<String, Object> extraResponseData`. The `getExtraVar(key)` helper safely extracts app-specific values (e.g. Homebox's `attachmentToken`).
|
||||||
package com.vaessl.app.dto;
|
|
||||||
|
|
||||||
import java.util.Map;
|
### Response (returned to frontend)
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
- **`LoginResult`** — `connectionId`, `expiresAt`. Returned by `ConnectionService.login()` and used by the controller to populate the session.
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
- **`AuthResponse`** — `serviceType`, `expiresAt`. Returned by `POST /api/login` to the frontend.
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
|
||||||
|
|
||||||
public record ConnectionRequest(
|
- **`ConnectionStatusResponse`** — `serviceType`, `appUrl`, `username`, `expiresAt`, `connected`. Returned by `GET /api/connections/status`.
|
||||||
@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 {
|
## Supported Service Types
|
||||||
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.
|
Registered in the `ServiceType` enum. Currently: `HOMEBOX`. Adding a new service means implementing `ConnectionProvider` and adding the discriminator value — no changes to `ConnectionService` or `ConnectionController` required.
|
||||||
|
|
||||||
- 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonl
|
|||||||
if (!dialog) return
|
if (!dialog) return
|
||||||
|
|
||||||
dialog.showModal()
|
dialog.showModal()
|
||||||
|
firstInputRef.current?.focus()
|
||||||
|
|
||||||
const handleCancel = (e: Event) => {
|
const handleCancel = (e: Event) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -51,34 +52,34 @@ export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonl
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog className="modal" ref={dialogRef}>
|
<dialog className="modal" ref={dialogRef}>
|
||||||
<div className="modal__header">
|
<div className="modal__header">
|
||||||
<h2 className="modal__title" id="modal-title">Connect to {label}</h2>
|
<h2 className="modal__title" id="modal-title">Connect to {label}</h2>
|
||||||
<button className="modal__close" onClick={onClose} aria-label="Close">×</button>
|
<button className="modal__close" onClick={onClose} aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<form className="modal__form" onSubmit={handleSubmit}>
|
||||||
|
<div className="modal__field">
|
||||||
|
<label className="modal__label" htmlFor="appUrl">App URL</label>
|
||||||
|
<input id="appUrl" ref={firstInputRef} className="modal__input" type="url"
|
||||||
|
placeholder="https://homebox.example.com"
|
||||||
|
value={appUrl} onChange={e => setAppUrl(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<form className="modal__form" onSubmit={handleSubmit}>
|
<div className="modal__field">
|
||||||
<div className="modal__field">
|
<label className="modal__label" htmlFor="username">Username</label>
|
||||||
<label className="modal__label" htmlFor="appUrl">App URL</label>
|
<input id="username" className="modal__input" type="text"
|
||||||
<input id="appUrl" ref={firstInputRef} className="modal__input" type="url"
|
autoComplete="username"
|
||||||
placeholder="https://homebox.example.com"
|
value={username} onChange={e => setUsername(e.target.value)} required />
|
||||||
value={appUrl} onChange={e => setAppUrl(e.target.value)} required />
|
</div>
|
||||||
</div>
|
<div className="modal__field">
|
||||||
<div className="modal__field">
|
<label className="modal__label" htmlFor="password">Password</label>
|
||||||
<label className="modal__label" htmlFor="username">Username</label>
|
<input id="password" className="modal__input" type="password"
|
||||||
<input id="username" className="modal__input" type="text"
|
autoComplete="current-password"
|
||||||
autoComplete="username"
|
value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
value={username} onChange={e => setUsername(e.target.value)} required />
|
</div>
|
||||||
</div>
|
{error && <p className="modal__error">{error}</p>}
|
||||||
<div className="modal__field">
|
<button className="modal__submit" type="submit" disabled={loading}>
|
||||||
<label className="modal__label" htmlFor="password">Password</label>
|
{loading ? 'Connecting…' : 'Connect'}
|
||||||
<input id="password" className="modal__input" type="password"
|
</button>
|
||||||
autoComplete="current-password"
|
</form>
|
||||||
value={password} onChange={e => setPassword(e.target.value)} required />
|
</dialog>
|
||||||
</div>
|
|
||||||
{error && <p className="modal__error">{error}</p>}
|
|
||||||
<button className="modal__submit" type="submit" disabled={loading}>
|
|
||||||
{loading ? 'Connecting…' : 'Connect'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user