diff --git a/CLAUDE.md b/CLAUDE.md index f2d149e..341e4a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,40 +43,56 @@ Frontend (optional, defaults to `/api`): ### Backend (`backend/src/main/java/com/vaessl/app/`) -Server context path is `/api`. Main endpoints: +Package-by-feature layout. 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 +- `POST /api/search` — paged search against the requested service; returns 401 if no active session -Four main modules: +Five packages: + +**`shared/`** — cross-cutting types used by more than one feature package + +- `ServiceType` (enum): identifies each integrated app (e.g. `HOMEBOX`); used in both `connection/` and `search/` +- `ServiceProvider` (interface): base for `ConnectionProvider` and `SearchProvider`; declares `getServiceType()` +- `Endpoint` (enum): API path constants for all external service calls +- `SessionKeys`: builds session attribute names of the form `{SERVICE_TYPE}_CONNECTION_ID` **`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/`** — connecting to and persisting service credentials -- `ConnectionProvider` interface: each integrated app (Homebox, WikiJS) implements `login()` and declares its `ServiceType` +- `ConnectionProvider` interface: extends `ServiceProvider`; each integrated app implements `login()` and credential checking - `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 (`ConnectionEntity`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns +- `HomeboxConnectionProvider` / `HomeboxEntity`: Homebox-specific implementation -**`dto/`** — `ConnectionRequest`, `LoginResult`, `AuthResponse`, `ConnectionStatusResponse` +**`search/`** — querying connected services + +- `SearchProvider` interface: extends `ServiceProvider`; each integrated app implements `getSearchResults()` +- `SearchService`: auto-discovers providers via Spring injection, dispatches by `ServiceType` +- `SearchController`: guards with session check before delegating to `SearchService` +- `HomeboxSearchProvider`: Homebox-specific search implementation using bearer token from session **`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice` ### Frontend (`frontend/src/`) -React 19 + TypeScript + SCSS, Vite 8 build. +React 19 + TypeScript + SCSS, Vite 6 build. Package-by-feature under `components/`. - `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 +- `api/connections.ts` — connection-specific API calls (`getStatuses`, `login`, `logout`) +- `api/searches.ts` — search API call (`search`) +- `types/connection.ts` — `ServiceType`, `LoginRequest`, `AuthResponse`, `ConnectionStatus` +- `types/search.ts` — `SearchRequest`, `SearchResponse`, `PagedSearchResponse` +- `components/connections/` — `Dashboard`, `ConnectModal`, `ServiceCard` (connection management UI) +- `components/search/SearchModal` — search dialog; posts to `/api/search` and renders paged results +- `components/ui/` — shared UI primitives (`ActionButton`, `Modal`) ### Data & AI diff --git a/README.md b/README.md index 0609850..c041ff3 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ This project serves as a transparent portfolio of my take on: --- ## What is Vaessl? -Vaessl acts as translator between user inputs via text or image and digital management systems. It performs image and semantic search analysis before serving results from applications (e.g., Homebox, WikiJS) via REST API. +Vaessl acts as a translator between user inputs via text or image and digital management systems. It performs image and semantic search analysis before serving results from applications (e.g., Homebox, WikiJS) via REST API. ### Core Functionality -* **Staging:** Raw images and metadata are stored in a PostgreSQL staging table. -* **AI-Powered Analysis:** The system utilizes **LiteLLM** as a unified gateway to process inputs via LLM of choice. -* **Semantic Discovery:** By utilizing **Spring AI** and **pgvector**, Vaessl stores description embeddings. This allows for intent-based search (e.g., finding a "Wrench" by searching for "tool to fix a leaky pipe"). +* **Connection Management:** Users connect to supported services (e.g. Homebox) via a credential login flow. Tokens are stored in a JDBC-backed HTTP session, enabling secure multi-service connections per browser session. +* **AI-Powered Analysis:** The system utilises **LiteLLM** as a unified gateway to process inputs via LLM of choice *(pipeline in progress)*. +* **Semantic Discovery:** By utilising **Spring AI** and **pgvector**, Vaessl will store description embeddings to enable intent-based search (e.g., finding a "Wrench" by searching for "tool to fix a leaky pipe") *(planned)*. --- @@ -22,8 +22,8 @@ Vaessl acts as translator between user inputs via text or image and digital mana | Layer | Technology | | :--- | :--- | -| **Backend** | Spring Boot 4.0.x, Java 25 (LTS), Spring AI | -| **Frontend** | React (Vite), Tailwind CSS, SCSS | +| **Backend** | Spring Boot 4.1.0, Java 25 (LTS), Spring AI | +| **Frontend** | React 19, Vite 8, SCSS | | **Database** | PostgreSQL + `pgvector` | | **AI Gateway** | LiteLLM (Unified API proxy) | | **DevOps** | Docker, Portainer, Hoppscotch (API testing), Gitea Actions, Jenkins | @@ -42,12 +42,50 @@ To maintain a zero-footprint local setup, I develop within a custom **code-serve ### 2. Abstraction & Scalability A core requirement was to prevent vendor or application lock-in: -* **API Abstraction:** While the initial integration targets **Homebox**, the export logic is decoupled. This allows the bridge to support other platforms like **WikiJS** by simply implementing new mapping profiles. +* **Provider Pattern:** Both connection and search logic are fully abstracted behind `ConnectionProvider` and `SearchProvider` interfaces. Adding a new service (e.g. WikiJS) means implementing those interfaces — no changes to core dispatch logic required. * **AI Agnostic:** LiteLLM abstracts the AI provider, allowing the backend to switch between cloud APIs and local inference engines (Ollama) without code changes. ### 3. Testing Strategy -* **Database Parity:** I utilize mirrored containers for development and testing (`vaessl-db` vs `vassal-test-db`). This ensures that JPA operations are tested against the real PostgreSQL engine and `pgvector` extensions rather than mocks. -* **Frontend TDD:** A Vitest-based stack provides a fast feedback loop for the UI, ensuring component reliability before integration with the Spring Boot backend. +* **Database Parity:** Mirrored PostgreSQL containers are used for development and testing (`vaessl-db` vs `vaessl-test-db`). This ensures JPA operations are tested against the real PostgreSQL engine and `pgvector` extension, not mocks. +* **WireMock:** External HTTP APIs (Homebox, WikiJS) are stubbed in integration tests using WireMock, isolating tests from live service availability. +* **Frontend:** A Vitest-based stack provides a fast feedback loop for UI components. + +--- + +## Architecture Overview + +### Backend (`backend/src/main/java/com/vaessl/app/`) + +Package-by-feature layout. Server context path is `/api`. + +| Package | Purpose | +| :--- | :--- | +| `shared/` | Cross-cutting types: `ServiceType` enum, `ServiceProvider` base interface, `Endpoint` enum, `SessionKeys` utility | +| `config/` | CORS (env-driven allowed origins) and JDBC-backed Spring Session | +| `connection/` | Login, session management, credential persistence (Single Table Inheritance) | +| `search/` | Paged search against connected services, guarded by active session | +| `exception/` | `GlobalExceptionHandler` via `@ControllerAdvice`; domain exceptions mapped to typed HTTP responses | + +**REST Endpoints:** + +| Method | Path | Description | +| :--- | :--- | :--- | +| `POST` | `/api/login` | Authenticates a service and stores its connection ID in the session | +| `GET` | `/api/connections/status` | Lists all connected services for the current session | +| `DELETE` | `/api/connections/{serviceType}` | Removes a service from the session; invalidates the session if none remain | +| `POST` | `/api/search` | Paged search against a connected service; returns 401 if no active session | + +### Frontend (`frontend/src/`) + +React 19 + TypeScript + SCSS, Vite 8 build. Package-by-feature under `components/`. + +| Module | Description | +| :--- | :--- | +| `api/` | Typed `apiFetch` wrapper + per-feature API call modules (`connections.ts`, `searches.ts`) | +| `types/` | Shared TS types aligned with backend records (`ServiceType`, `LoginRequest`, `SearchResponse`, etc.) | +| `components/connections/` | `Dashboard`, `ConnectModal`, `ServiceCard` — full connection management UI | +| `components/search/` | `SearchModal` — search dialog with paged result rendering | +| `components/ui/` | Shared primitives (`ActionButton`, `Modal`) | --- @@ -58,32 +96,57 @@ A core requirement was to prevent vendor or application lock-in: * [x] Custom Code-Server image build and deployment. * [x] Spring Boot skeleton with Java 25 and Gradle Kotlin DSL. -### Phase 2: The Processing Pipeline (Current) -* [ ] Implementation of Image/Semantic Search $\rightarrow$ AI $\rightarrow$ Staging Table workflow. -* [ ] Development of the data refinement UI in React. -* [ ] Initial API connector for Homebox. +### Phase 2: Connection & Basic Search (Current) +* [x] Provider-pattern abstraction for connections and search. +* [x] Homebox authentication — login, session-tracked token, expiry checking. +* [x] Paged search against Homebox entities via REST. +* [x] React connection management dashboard (connect, disconnect, status). +* [x] React search modal with paged results. +* [x] Integration test suite (WireMock + mirrored PostgreSQL container). +* [ ] Image/text input → LLM inference → staging table workflow. +* [ ] Data refinement UI for reviewing and approving LLM-processed results. ### Phase 3: Semantic Search & Expansion -* [ ] Integration of Spring AI vector embeddings. -* [ ] Implementation of additional API targets (WikiJS). -* [ ] Launch of a public-facing demo. +* [ ] Spring AI vector embeddings with pgvector for intent-based search. +* [ ] Additional API targets (WikiJS). +* [ ] Public-facing demo. --- ## Setup & Deployment -The project is orchestrated via Docker Compose for high portability. -1. Clone the Repository. -2. Environment Setup: Configure `.env.local` with your database credentials and AI API keys. +1. Clone the repository. +2. Copy `.env.local` into `backend/` with: -``` +```env DB_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl DB_TEST_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl_test DB_USERNAME={username} DB_PASSWORD={password} -OPENAI_KEY={api-key} # or LLM provider of choice -OPENAI_BASE_URL=https://api.openai.com/v1 PG_DRIVER_CLASS_NAME=org.postgresql.Driver +OPENAI_KEY={api-key} +OPENAI_BASE_URL=https://{litellm-host}/v1 +FRONTEND_LOCAL_URL=http://localhost:5173 +FRONTEND_PUBLIC_URL=https://{your-public-domain} +``` + +3. Start the pgvector-enabled PostgreSQL instances (production + test) — use the official `pgvector/pgvector` Docker image or install the extension into your existing PostgreSQL instance. +4. Install frontend dependencies and build the backend: + +```bash +# Frontend +cd frontend && npm install + +# Backend +cd backend && ./gradlew build +``` + +5. Run the backend and frontend dev servers: + +```bash +# Backend +cd backend && ./gradlew bootRun + +# Frontend +cd frontend && npm run dev ``` -3. Setup both production and test pgvector databases using the official pgvector docker image or installing the pgvector extension to you existing PostGreSQL database. -4. Execute "npm i" and build gradle. \ No newline at end of file diff --git a/backend/src/main/java/com/vaessl/app/config/CorsConfig.java b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java index 945686a..f9ce4f8 100644 --- a/backend/src/main/java/com/vaessl/app/config/CorsConfig.java +++ b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java @@ -8,18 +8,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { - @Value("${vaessl.frontend-local-url}") - private String frontendLocalUrl; - - @Value("${vaessl.frontend-public-url}") - private String frontendPublicUrl; + @Value("${vaessl.allowed-origins}") + private String[] allowedOrigins; @Override public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOrigins(frontendLocalUrl, frontendPublicUrl) + registry.addMapping("/**").allowedOrigins(allowedOrigins) .allowedMethods("GET", "POST", "DELETE", "OPTIONS") - .allowedHeaders("Content-Type", "Accept") - .allowCredentials(true); + .allowedHeaders("Content-Type", "Accept").allowCredentials(true); } } diff --git a/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java b/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java new file mode 100644 index 0000000..42a0a51 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/AuthResponse.java @@ -0,0 +1,8 @@ +package com.vaessl.app.connection; + +import java.time.Instant; + +import com.vaessl.app.shared.ServiceType; + +public record AuthResponse(ServiceType serviceType, Instant expiresAt) { +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java index 3dfa944..1c8536a 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -14,33 +14,28 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import com.vaessl.app.dto.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.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import com.vaessl.app.shared.ServiceType; +import com.vaessl.app.shared.SessionKeys; + @RestController @RequiredArgsConstructor public class ConnectionController { - private static final String SUFFIX = "_CONNECTION_ID"; - private final ConnectionService connectionService; @PostMapping("/login") - public ResponseEntity login( - @Valid @RequestBody ConnectionRequest request, + public ResponseEntity login(@Valid @RequestBody ConnectionRequest request, HttpServletRequest httpReq) { LoginResult result = connectionService.login(request); HttpSession session = httpReq.getSession(true); - session.setAttribute(request.serviceType() + SUFFIX, result.connectionId()); + session.setAttribute(SessionKeys.connectionId(request.serviceType()), result.connectionId()); if (result.expiresAt() != null) { long secs = Instant.now().until(result.expiresAt(), ChronoUnit.SECONDS); @@ -59,27 +54,33 @@ public class ConnectionController { List statuses = new ArrayList<>(); Collections.list(session.getAttributeNames()).stream() - .filter(k -> k.endsWith(SUFFIX)) + .filter(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX)) .forEach(k -> { - String serviceType = k.replace(SUFFIX, ""); - Long id = (Long) session.getAttribute(k); - ConnectionStatusResponse status = connectionService.getConnectionStatus(serviceType, id); - if (status != null) statuses.add(status); + String name = k.replace(SessionKeys.CONNECTION_ID_SUFFIX, ""); + try { + ServiceType serviceType = ServiceType.valueOf(name); + Long id = (Long) session.getAttribute(k); + ConnectionStatusResponse status = + connectionService.getConnectionStatus(serviceType, id); + if (status != null) + statuses.add(status); + } catch (IllegalArgumentException _) { + // stale session key from an old or removed service type — skip it + } }); return ResponseEntity.ok(statuses); } @DeleteMapping("/connections/{serviceType}") - public ResponseEntity logout( - @PathVariable("serviceType") String serviceType, + public ResponseEntity logout(@PathVariable("serviceType") ServiceType serviceType, HttpServletRequest httpReq) { HttpSession session = httpReq.getSession(false); if (session != null) { - session.removeAttribute(serviceType + SUFFIX); + session.removeAttribute(SessionKeys.connectionId(serviceType)); boolean hasMore = Collections.list(session.getAttributeNames()).stream() - .anyMatch(k -> k.endsWith(SUFFIX)); + .anyMatch(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX)); if (!hasMore) { session.invalidate(); } diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java index 96b769f..586921a 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java @@ -14,7 +14,8 @@ import lombok.NoArgsConstructor; import lombok.Setter; @Entity -@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) }) +@Table(name = "connections", + uniqueConstraints = {@UniqueConstraint(columnNames = {"appUrl", "username"})}) @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "service_type") @Getter diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java index 91a6adb..33ee47d 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -2,15 +2,12 @@ package com.vaessl.app.connection; import java.time.Instant; -import com.vaessl.app.dto.ConnectionRequest; -import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.shared.ServiceProvider; -public interface ConnectionProvider { +public interface ConnectionProvider extends ServiceProvider { void checkCredentials(ConnectionRequest request); - String getServiceType(); - ConnectionResponse authenticate(ConnectionRequest request); ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request); diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java new file mode 100644 index 0000000..4cc5d91 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionRequest.java @@ -0,0 +1,24 @@ +package com.vaessl.app.connection; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.vaessl.app.shared.ServiceType; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ConnectionRequest(@NotBlank(message = "App URL is mandatory") String appUrl, + @NotNull(message = "Service type is mandatory") ServiceType serviceType, + String username, String password, String apiKey, + @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { + + public ConnectionRequest { + if (stayLoggedIn == null) { + stayLoggedIn = false; + } + } + + public ConnectionRequest(String appUrl, ServiceType serviceType, String username, + String password, Boolean stayLoggedIn) { + this(appUrl, serviceType, username, password, null, stayLoggedIn); + } +} diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionResponse.java similarity index 72% rename from backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java rename to backend/src/main/java/com/vaessl/app/connection/ConnectionResponse.java index 9422e7d..41b278c 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionResponse.java @@ -1,12 +1,13 @@ -package com.vaessl.app.dto; +package com.vaessl.app.connection; import java.time.Instant; import java.util.Map; -public record ConnectionResponse(String token, Instant expiresAt, Map extraResponseData) { - +public record ConnectionResponse(String token, Instant expiresAt, + Map extraResponseData) { + public String getExtraVar(String key) { - if(extraResponseData == null) { + if (extraResponseData == null) { return null; } else { Object value = extraResponseData.get(key); diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java index fed0b7d..518e0f9 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -7,16 +7,13 @@ 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.dto.ConnectionStatusResponse; -import com.vaessl.app.dto.LoginResult; import com.vaessl.app.exception.WrongServiceTypeException; +import com.vaessl.app.shared.ServiceType; @Service public class ConnectionService { - private final Map providerRegistry; + private final Map providerRegistry; private final ConnectionRepository cRepository; @@ -52,15 +49,16 @@ public class ConnectionService { return new LoginResult(saved.getId(), response.expiresAt()); } - public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) { + public ConnectionStatusResponse getConnectionStatus(ServiceType serviceType, Long connectionId) { ConnectionEntity entity = cRepository.findById(connectionId).orElse(null); - if (entity == null) return null; + if (entity == null) + return null; ConnectionProvider provider = providerRegistry.get(serviceType); Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null; boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now()); - return new ConnectionStatusResponse(serviceType, entity.getAppUrl(), - entity.getUsername(), expiresAt, connected); + return new ConnectionStatusResponse(serviceType.name(), entity.getAppUrl(), entity.getUsername(), + expiresAt, connected); } } diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java new file mode 100644 index 0000000..be5ee04 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionStatusResponse.java @@ -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) { +} diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java deleted file mode 100644 index d8b7426..0000000 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.vaessl.app.connection; - -public enum Endpoint { - HOMEBOX_LOGIN("/api/v1/users/login"), - LOGIN("/login"), - CONNECTION_STATUS("/connections/status"); - - private final String value; - - private Endpoint(String value) { - this.value = value; - } - - public String getValue() { - return value; - } -} diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java similarity index 71% rename from backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java rename to backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java index 1c50baa..faffb79 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -9,20 +9,21 @@ import java.util.Map; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; -import com.vaessl.app.dto.ConnectionRequest; -import com.vaessl.app.dto.ConnectionResponse; import com.vaessl.app.exception.EmptyCredentialsException; +import com.vaessl.app.exception.RemoteApiException; +import com.vaessl.app.shared.ServiceType; -import static com.vaessl.app.connection.Endpoint.*; +import static com.vaessl.app.shared.Endpoint.*; @Component -public class HomeBoxConnectionProvider implements ConnectionProvider { +public class HomeboxConnectionProvider implements ConnectionProvider { private final RestClient.Builder restClientBuilder; private final ConnectionRepository cRepository; - public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { + public HomeboxConnectionProvider(RestClient.Builder restClientBuilder, + ConnectionRepository cRepository) { this.restClientBuilder = restClientBuilder; this.cRepository = cRepository; } @@ -44,33 +45,32 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { } @Override - public String getServiceType() { - return "HOMEBOX"; + public ServiceType getServiceType() { + return ServiceType.HOMEBOX; } @Override public ConnectionResponse authenticate(ConnectionRequest request) { - Map homeboxPayload = Map.of("username", request.username(), - "password", request.password(), "stayLoggedIn", - request.stayLoggedIn()); + Map homeboxPayload = Map.of("username", request.username(), "password", + request.password(), "stayLoggedIn", request.stayLoggedIn()); - HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) - .build() - .post() - .uri(HOMEBOX_LOGIN.getValue()) - .body(homeboxPayload) - .retrieve() + HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().post() + .uri(HOMEBOX_LOGIN.getValue()).body(homeboxPayload).retrieve() .body(HomeboxLoginResponse.class); if (hbResponse == null) { - throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl()); + throw new RemoteApiException(request.appUrl(), HOMEBOX_LOGIN.getValue()); } Map attachmentToken = new HashMap<>(); 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 @@ -80,7 +80,8 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { } @Override - public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { + public ConnectionEntity connectionToEntity(ConnectionRequest request, + ConnectionResponse response) { return HomeboxEntity.from(request, response); } @@ -105,4 +106,5 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { } + } diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java index e965269..e15ea57 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java @@ -2,9 +2,6 @@ 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; diff --git a/backend/src/main/java/com/vaessl/app/dto/LoginResult.java b/backend/src/main/java/com/vaessl/app/connection/LoginResult.java similarity index 70% rename from backend/src/main/java/com/vaessl/app/dto/LoginResult.java rename to backend/src/main/java/com/vaessl/app/connection/LoginResult.java index 8e54eea..49349ec 100644 --- a/backend/src/main/java/com/vaessl/app/dto/LoginResult.java +++ b/backend/src/main/java/com/vaessl/app/connection/LoginResult.java @@ -1,5 +1,6 @@ -package com.vaessl.app.dto; +package com.vaessl.app.connection; import java.time.Instant; -public record LoginResult(Long connectionId, Instant expiresAt) {} +public record LoginResult(Long connectionId, Instant expiresAt) { +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java deleted file mode 100644 index d579690..0000000 --- a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.vaessl.app.connection; - -import lombok.Getter; - -@Getter -public enum ServiceType { - HOMEBOX("HOMEBOX"); - - private final String value; - - private ServiceType(String value){ - this.value = value; - } -} diff --git a/backend/src/main/java/com/vaessl/app/dto/AuthResponse.java b/backend/src/main/java/com/vaessl/app/dto/AuthResponse.java deleted file mode 100644 index a16a725..0000000 --- a/backend/src/main/java/com/vaessl/app/dto/AuthResponse.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.vaessl.app.dto; - -import java.time.Instant; - -public record AuthResponse(String serviceType, Instant expiresAt) {} diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java deleted file mode 100644 index 3c6272d..0000000 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.vaessl.app.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import jakarta.validation.constraints.NotBlank; - -public record ConnectionRequest( - @NotBlank(message = "App URL is mandatory") String appUrl, - @NotBlank(message = "Service type is mandatory") String serviceType, - String username, - String password, - String apiKey, - @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { - - public ConnectionRequest { - if (stayLoggedIn == null) { - stayLoggedIn = false; - } - } - - public ConnectionRequest(String appUrl, String serviceType, String username, String password, - Boolean stayLoggedIn) { - this(appUrl, serviceType, username, password, null, stayLoggedIn); - } -} diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java deleted file mode 100644 index 77f73a6..0000000 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java +++ /dev/null @@ -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) {} diff --git a/backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java b/backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java new file mode 100644 index 0000000..d02d713 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java @@ -0,0 +1,4 @@ +package com.vaessl.app.exception; + +public class ConnectionNotFoundException extends RuntimeException { +} diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java index ae14081..7e4fc96 100644 --- a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java @@ -5,11 +5,20 @@ import org.springframework.http.HttpStatus; import com.fasterxml.jackson.annotation.JsonValue; public enum ErrorMessage { - BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN( - HttpStatus.UNAUTHORIZED, "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL( - HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."), SERVER_ERROR_GENERAL( - "The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.NOT_FOUND, - "No such service type."); + BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, + "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(HttpStatus.UNAUTHORIZED, + "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL( + HttpStatus.SERVICE_UNAVAILABLE, + "The target URL is unreachable."), SERVER_ERROR_GENERAL( + "The external app returned a server error: "), WRONG_SERVICE_TYPE( + HttpStatus.NOT_FOUND, + "No such service type."), 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 "), REMOTE_API_CLIENT_ERROR( + HttpStatus.BAD_GATEWAY, + "Remote API returned an unexpected error: "); private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java index 08994c0..8019f6a 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -1,5 +1,7 @@ package com.vaessl.app.exception; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ProblemDetail; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -16,11 +18,13 @@ import java.util.stream.Collectors; @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) { - String defaultMessages = e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage) - .collect(Collectors.joining(", ")); + String defaultMessages = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage).collect(Collectors.joining(", ")); return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]"); @@ -33,6 +37,15 @@ public class GlobalExceptionHandler { UNAUTHORIZED_WRONG_LOGIN.getMessage()); } + // Catches any other 4xx from a remote API (e.g. 404 caused by a changed endpoint). + // Must come after the Unauthorized handler so Spring matches 401 there first. + @ExceptionHandler(HttpClientErrorException.class) + public ProblemDetail handleRemoteClientError(HttpClientErrorException e) { + log.error("Remote API returned {}: {}", e.getStatusCode(), e.getResponseBodyAsString()); + return ProblemDetail.forStatusAndDetail(REMOTE_API_CLIENT_ERROR.getStatus(), + REMOTE_API_CLIENT_ERROR.getMessage() + e.getStatusCode()); + } + @ExceptionHandler(ResourceAccessException.class) public ProblemDetail handleNoConnection(ResourceAccessException e) { @@ -42,15 +55,15 @@ public class GlobalExceptionHandler { @ExceptionHandler(HttpServerErrorException.class) public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) { - - return ProblemDetail - .forStatusAndDetail(e.getStatusCode(), - SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); + log.error("Remote API server error {}: {}", e.getStatusCode(), e.getResponseBodyAsString()); + return ProblemDetail.forStatusAndDetail(e.getStatusCode(), + SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); } @ExceptionHandler(WrongServiceTypeException.class) public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) { - return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage()); + return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), + WRONG_SERVICE_TYPE.getMessage()); } @ExceptionHandler(EmptyCredentialsException.class) @@ -58,4 +71,17 @@ public class GlobalExceptionHandler { return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), 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) { + log.error("Remote API empty body: {}{}", e.getAppUrl(), e.getPath()); + return ProblemDetail.forStatusAndDetail(REMOTE_API_EMPTY_RESPONSE.getStatus(), + REMOTE_API_EMPTY_RESPONSE.getMessage() + e.getAppUrl() + e.getPath()); + } } diff --git a/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java b/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java new file mode 100644 index 0000000..35ac62c --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/RemoteApiException.java @@ -0,0 +1,16 @@ +package com.vaessl.app.exception; + +import lombok.Getter; + +@Getter +public class RemoteApiException extends RuntimeException { + + private final String appUrl; + private final String path; + + public RemoteApiException(String appUrl, String path) { + super("Remote API returned empty body: " + appUrl + path); + this.appUrl = appUrl; + this.path = path; + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java new file mode 100644 index 0000000..5131092 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java @@ -0,0 +1,81 @@ +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 com.vaessl.app.shared.ServiceType; + +import static com.vaessl.app.shared.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 ServiceType getServiceType() { + return ServiceType.HOMEBOX; + } + + @Override + public Page 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(), HOMEBOX_QUERY_ALL_ITEMS.getValue()); + } + + List items = hbResponse.items().stream().map(i -> { + String id = i.id(); + String title = i.name(); + String description = i.description(); + Map extraSearchResponseData = Map.of("location", i.parent()); + return new SearchResponse(id, title, description, extraSearchResponseData); + }).toList(); + + return new PageImpl<>(items, pageable, hbResponse.total()); + } + + private record HomeboxSearchResponse(int page, int pageSize, int total, + List items) { + } + + private record HomeboxItem(String id, String name, String description, HomeboxLocation parent) { + } + + private record HomeboxLocation(String name, String description) { + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java b/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java new file mode 100644 index 0000000..9da5d55 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java @@ -0,0 +1,14 @@ +package com.vaessl.app.search; + +import java.util.List; +import org.springframework.data.domain.Page; + +public record PagedSearchResponse(List content, int page, int pageSize, long totalElements, + boolean first, boolean last, String sort) { + + public static PagedSearchResponse from(Page pageResult) { + return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(), + pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(), + pageResult.isLast(), pageResult.getSort().toString()); + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchController.java b/backend/src/main/java/com/vaessl/app/search/SearchController.java new file mode 100644 index 0000000..a96f47f --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchController.java @@ -0,0 +1,42 @@ +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 com.vaessl.app.shared.SessionKeys; +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> search( + @Valid @RequestBody SearchRequest request, + @PageableDefault(size = 20) Pageable pageable, HttpServletRequest httpReq) { + HttpSession session = httpReq.getSession(false); + if (session == null + || session.getAttribute(SessionKeys.connectionId(request.serviceType())) == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + Page result = searchService.search(request, pageable); + + return ResponseEntity.ok(PagedSearchResponse.from(result)); + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchProvider.java b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java new file mode 100644 index 0000000..ef6196c --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java @@ -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.shared.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 items matching the query + */ + Page getSearchResults(SearchRequest request, Pageable pageable); +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchRequest.java b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java new file mode 100644 index 0000000..bc618e9 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java @@ -0,0 +1,9 @@ +package com.vaessl.app.search; + +import com.vaessl.app.shared.ServiceType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query, + @NotNull ServiceType serviceType) { +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchResponse.java b/backend/src/main/java/com/vaessl/app/search/SearchResponse.java new file mode 100644 index 0000000..5327334 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchResponse.java @@ -0,0 +1,17 @@ +package com.vaessl.app.search; + +import java.util.Map; + +public record SearchResponse(String id, String title, String description, + Map extraData) { + + public String getExtra(String key) { + if (extraData == null) { + return null; + } else { + Object value = extraData.get(key); + + return value != null ? String.valueOf(value) : null; + } + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchService.java b/backend/src/main/java/com/vaessl/app/search/SearchService.java new file mode 100644 index 0000000..a5314c7 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchService.java @@ -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.shared.ServiceType; +import com.vaessl.app.exception.WrongServiceTypeException; + +@Service +public class SearchService { + + private final Map providerRegistry; + + public SearchService(List 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 search(SearchRequest request, Pageable pageable) { + + SearchProvider provider = providerRegistry.get(request.serviceType()); + + if (provider == null) { + throw new WrongServiceTypeException(); + } + + return provider.getSearchResults(request, pageable); + } +} diff --git a/backend/src/main/java/com/vaessl/app/shared/Endpoint.java b/backend/src/main/java/com/vaessl/app/shared/Endpoint.java new file mode 100644 index 0000000..26682e4 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/shared/Endpoint.java @@ -0,0 +1,16 @@ +package com.vaessl.app.shared; + +public enum Endpoint { + HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS( + "/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/entities"), SEARCH("/search"); + + private final String value; + + private Endpoint(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/backend/src/main/java/com/vaessl/app/shared/ServiceProvider.java b/backend/src/main/java/com/vaessl/app/shared/ServiceProvider.java new file mode 100644 index 0000000..d2ccc6a --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/shared/ServiceProvider.java @@ -0,0 +1,6 @@ +package com.vaessl.app.shared; + +public interface ServiceProvider { + + ServiceType getServiceType(); +} diff --git a/backend/src/main/java/com/vaessl/app/shared/ServiceType.java b/backend/src/main/java/com/vaessl/app/shared/ServiceType.java new file mode 100644 index 0000000..1f060c6 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/shared/ServiceType.java @@ -0,0 +1,5 @@ +package com.vaessl.app.shared; + +public enum ServiceType { + HOMEBOX; +} diff --git a/backend/src/main/java/com/vaessl/app/shared/SessionKeys.java b/backend/src/main/java/com/vaessl/app/shared/SessionKeys.java new file mode 100644 index 0000000..5985fee --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/shared/SessionKeys.java @@ -0,0 +1,12 @@ +package com.vaessl.app.shared; + +public final class SessionKeys { + + public static final String CONNECTION_ID_SUFFIX = "_CONNECTION_ID"; + + private SessionKeys() {} + + public static String connectionId(ServiceType serviceType) { + return serviceType.name() + CONNECTION_ID_SUFFIX; + } +} diff --git a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 57fd745..67507e3 100644 --- a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -5,13 +5,8 @@ "description": "A description for 'spring.session.store-type'" }, { - "name": "vaessl.frontend-local-url", + "name": "vaessl.allowed-origins", "type": "java.lang.String", - "description": "A description for 'vaessl.frontend-local-url'" - }, - { - "name": "vaessl.frontend-public-url", - "type": "java.lang.String", - "description": "A description for 'vaessl.frontend-public-url'" + "description": "Comma-separated list of allowed CORS origins" } ]} \ No newline at end of file diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index f9c6a95..398cf02 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -13,15 +13,14 @@ spring: driver-class-name: ${PG_DRIVER_CLASS_NAME} jpa: hibernate: - ddl-auto: create-drop + ddl-auto: update show-sql: true ai: openai: base-url: ${OPENAI_BASE_URL} api-key: ${OPENAI_KEY} chat: - options: - model: gpt-4o-mini + model: gpt-4o-mini session: store-type: jdbc jdbc: @@ -30,5 +29,4 @@ server: servlet: context-path: /api vaessl: - frontend-local-url: ${FRONTEND_LOCAL_URL} - frontend-public-url: ${FRONTEND_PUBLIC_URL} + allowed-origins: ${ALLOWED_ORIGINS} diff --git a/backend/src/test/java/com/vaessl/app/ApplicationTests.java b/backend/src/test/java/com/vaessl/app/ApplicationTests.java index b2954e9..40849b1 100644 --- a/backend/src/test/java/com/vaessl/app/ApplicationTests.java +++ b/backend/src/test/java/com/vaessl/app/ApplicationTests.java @@ -20,8 +20,7 @@ class ApplicationTests { private DataSource dataSource; @Test - void contextLoads() { - } + void contextLoads() {} @Test void connectionToTestDbWorks() throws SQLException { diff --git a/backend/src/test/java/com/vaessl/app/Mockdata.java b/backend/src/test/java/com/vaessl/app/Mockdata.java new file mode 100644 index 0000000..4ebf98b --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/Mockdata.java @@ -0,0 +1,16 @@ +package com.vaessl.app; + +import com.vaessl.app.shared.ServiceType; + +public final class Mockdata { + + private Mockdata() {} + + public static final String MOCK_URL = "http://localhost:1234"; + public static final ServiceType MOCK_SERVICE_TYPE = ServiceType.HOMEBOX; + public static final String MOCK_USER = "user"; + public static final String MOCK_PASS = "pw"; + public static final String MOCK_ID = "item-1"; + public static final String MOCK_TITLE = "title"; + public static final String MOCK_DESCRIPTION = "desc"; +} diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java index 906fd42..79f90cb 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java @@ -1,7 +1,9 @@ package com.vaessl.app.connection; -import static com.vaessl.app.connection.Endpoint.*; -import static com.vaessl.app.connection.ServiceType.*; +import static com.vaessl.app.Mockdata.*; + +import static com.vaessl.app.shared.Endpoint.*; +import static com.vaessl.app.shared.ServiceType.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -24,134 +26,130 @@ import org.springframework.test.web.servlet.MvcResult; @WireMockTest class ConnectionControllerTest { - @Autowired - MockMvc mockMvc; + @Autowired + MockMvc mockMvc; - private static final String TEST_USER = "admin"; - private static final String TEST_PASS = "pw"; - private static final String LOGIN_PATH = LOGIN.getValue(); - private static final String STATUS_PATH = CONNECTION_STATUS.getValue(); - private static final String LOGOUT_PATH = "/connections/HOMEBOX"; + private static final String LOGIN_PATH = LOGIN.getValue(); + private static final String STATUS_PATH = CONNECTION_STATUS.getValue(); + private static final String LOGOUT_PATH = "/connections/HOMEBOX"; + private static final String SESSION = "SESSION"; - private static final String VALID_HOMEBOX_RESPONSE = """ - { - "token": "fake-jwt-token", - "attachmentToken": "fake-attach", - "expiresAt": "2099-01-01T00:00:00Z" - } - """; + private static final String VALID_HOMEBOX_RESPONSE = """ + { + "token": "fake-jwt-token", + "attachmentToken": "fake-attach", + "expiresAt": "2099-01-01T00:00:00Z" + } + """; - private static final String EXPIRED_HOMEBOX_RESPONSE = """ - { - "token": "expired-token", - "attachmentToken": "fake-attach", - "expiresAt": "2000-01-01T00:00:00Z" - } - """; + private static final String EXPIRED_HOMEBOX_RESPONSE = """ + { + "token": "expired-token", + "attachmentToken": "fake-attach", + "expiresAt": "2000-01-01T00:00:00Z" + } + """; - @Test - void shouldReturnEmptyListWhenNoActiveSession() throws Exception { - mockMvc.perform(get(STATUS_PATH)) - .andExpect(status().isOk()) - .andExpect(content().json("[]")); - } + @Test + void shouldReturnEmptyListWhenNoActiveSession() throws Exception { + mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk()) + .andExpect(content().json("[]")); + } - @Test - void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm) throws Exception { - WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); + @Test + void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm) + throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()) + .willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); - MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) - .contentType(MediaType.APPLICATION_JSON) - .content(connectionRequestBody(wm))) - .andExpect(status().isOk()) - .andReturn(); + MvcResult loginResult = mockMvc + .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON) + .content(connectionRequestBody(wm))) + .andExpect(status().isOk()).andReturn(); - Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION); - mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue())) - .andExpect(jsonPath("$[0].username").value(TEST_USER)) - .andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl())) - .andExpect(jsonPath("$[0].connected").value(true)); - } + mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.name())) + .andExpect(jsonPath("$[0].username").value(MOCK_USER)) + .andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl())) + .andExpect(jsonPath("$[0].connected").value(true)); + } - @Test - void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm) throws Exception { - WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE))); + @Test + void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm) + throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()) + .willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE))); - MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) - .contentType(MediaType.APPLICATION_JSON) - .content(connectionRequestBody(wm))) - .andExpect(status().isOk()) - .andReturn(); + MvcResult loginResult = mockMvc + .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON) + .content(connectionRequestBody(wm))) + .andExpect(status().isOk()).andReturn(); - Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION); - mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue())) - .andExpect(jsonPath("$[0].connected").value(false)) - .andExpect(jsonPath("$[0].expiresAt").value("2000-01-01T00:00:00Z")); - } + mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk()) + .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.name())) + .andExpect(jsonPath("$[0].connected").value(false)) + .andExpect(jsonPath("$[0].expiresAt") + .value("2000-01-01T00:00:00Z")); + } - @Test - void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception { - WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); + @Test + void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()) + .willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); - MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) - .contentType(MediaType.APPLICATION_JSON) - .content(connectionRequestBody(wm))) - .andExpect(status().isOk()) - .andReturn(); + MvcResult loginResult = mockMvc + .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON) + .content(connectionRequestBody(wm))) + .andExpect(status().isOk()).andReturn(); - Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION); - mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) - .andExpect(status().isNoContent()); - } + mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) + .andExpect(status().isNoContent()); + } - @Test - void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception { - WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); + @Test + void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()) + .willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); - MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) - .contentType(MediaType.APPLICATION_JSON) - .content(connectionRequestBody(wm))) - .andExpect(status().isOk()) - .andReturn(); + MvcResult loginResult = mockMvc + .perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON) + .content(connectionRequestBody(wm))) + .andExpect(status().isOk()).andReturn(); - Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION); - // Verify connected before logout - mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)); + // Verify connected before logout + mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); - mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) - .andExpect(status().isNoContent()); + mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) + .andExpect(status().isNoContent()); - // A new request (no session cookie, as in a fresh browser) returns no connections - mockMvc.perform(get(STATUS_PATH)) - .andExpect(status().isOk()) - .andExpect(content().json("[]")); - } + // A new request (no session cookie, as in a fresh browser) returns no connections + mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk()) + .andExpect(content().json("[]")); + } - @Test - void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception { - mockMvc.perform(delete(LOGOUT_PATH)) - .andExpect(status().isNoContent()); - } + @Test + void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception { + mockMvc.perform(delete(LOGOUT_PATH)).andExpect(status().isNoContent()); + } - private String connectionRequestBody(WireMockRuntimeInfo wm) { - return """ - { - "appUrl": "%s", - "serviceType": "HOMEBOX", - "username": "%s", - "password": "%s" - } - """.formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS); - } + private String connectionRequestBody(WireMockRuntimeInfo wm) { + return """ + { + "appUrl": "%s", + "serviceType": "HOMEBOX", + "username": "%s", + "password": "%s" + } + """.formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS); + } } diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java index 2c626f9..410a977 100644 --- a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java @@ -1,5 +1,6 @@ package com.vaessl.app.connection; +import static com.vaessl.app.Mockdata.*; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; @@ -15,11 +16,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.exception.EmptyCredentialsException; -import static com.vaessl.app.connection.Mockdata.*; - @ExtendWith(MockitoExtension.class) class ConnectionServiceTest { @@ -38,13 +36,14 @@ class ConnectionServiceTest { @Test 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"))) - .when(mockProvider).checkCredentials(request); + doThrow(new EmptyCredentialsException(List.of("username"))).when(mockProvider) + .checkCredentials(request); assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request)); verify(mockProvider, never()).authenticate(any()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java similarity index 66% rename from backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java rename to backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java index eaaca00..8b5816e 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxConnectionProviderTest.java @@ -4,19 +4,18 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; -import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.exception.EmptyCredentialsException; - +import static com.vaessl.app.Mockdata.*; +import static com.vaessl.app.shared.ServiceType.HOMEBOX; 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 - void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() { - ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false); + void checkCredentialsShouldThrowExceptionWhenFieldsAreMissing() { + ConnectionRequest request = new ConnectionRequest(MOCK_URL, HOMEBOX, null, null, false); EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> { provider.checkCredentials(request); diff --git a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java index 6003cbb..582bd2c 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -1,11 +1,16 @@ package com.vaessl.app.connection; +import static com.vaessl.app.Mockdata.*; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.resttestclient.TestRestTemplate; import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import com.github.tomakehurst.wiremock.http.Fault; @@ -14,196 +19,195 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; -import com.vaessl.app.dto.ConnectionRequest; import static org.assertj.core.api.Assertions.assertThat; import static com.github.tomakehurst.wiremock.client.WireMock.*; -import static com.vaessl.app.connection.Endpoint.*; +import static com.vaessl.app.shared.Endpoint.*; import static com.vaessl.app.exception.ErrorMessage.*; -import static com.vaessl.app.connection.ServiceType.*; +import static com.vaessl.app.shared.ServiceType.*; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestRestTemplate @WireMockTest class HomeboxIntegrationTest { - @Autowired - TestRestTemplate restTemplate; + @Autowired + TestRestTemplate restTemplate; - @Autowired - ConnectionRepository cRepository; + @Autowired + ConnectionRepository cRepository; - String okJsonHomeboxResponse = """ - { - "token": "fake-jwt-token", - "attachmentToken": "fake-attach", - "expiresAt": "2026-04-26T02:23:13Z" - } - """; + String okJsonHomeboxResponse = """ + { + "token": "fake-jwt-token", + "attachmentToken": "fake-attach", + "expiresAt": "2026-04-26T02:23:13Z" + } + """; - private static final String TEST_USER = "admin"; - private static final String TEST_PASS = "pw"; + /** + * Returns Token and status code OK when login is successful. + * + * @param wm the WiremockRuntimeInfo object + */ + @Test + void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { - /** - * Returns Token and status code OK when login is successful. - * - * @param wm the WiremockRuntimeInfo object - */ - @Test - void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse))); - stubFor(post(HOMEBOX_LOGIN.getValue()) - .willReturn(okJson(okJsonHomeboxResponse))); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + connectionRequest(wm), String.class); - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), - String.class); + DocumentContext documentContext = JsonPath.parse(response.getBody()); - DocumentContext documentContext = JsonPath.parse(response.getBody()); + String serviceType = documentContext.read("$.serviceType"); + assertThat(serviceType).isEqualTo("HOMEBOX"); - String serviceType = documentContext.read("$.serviceType"); - assertThat(serviceType).isEqualTo("HOMEBOX"); - - String expiresAt = documentContext.read("$.expiresAt", String.class); - assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z"); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - } - - /** - * Tests the Unauthorized custom exception. - * - * @param wm the WiremockRuntimeInfo object - */ - @Test - void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { - - stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); - - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), - String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage()); - } - - /** - * Tests a server error from the external api. - * - * @param wm the WiremockRuntimeInfo object - */ - @Test - void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { - - stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); - - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), - String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage()); - } - - /** - * Checks when the service is unavailable or the app URL is wrong. - */ - @Test - void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) { - - stubFor(post(HOMEBOX_LOGIN.getValue()) - .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); - - ConnectionRequest badRequest = connectionRequest(wm); - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); - } - - /** - * Checks if any login fields are empty since all of them are mandatory. - */ - @Test - void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() { - - ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false); - - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); - } - - /** - * Test the exception when there is an input for serviceType but it's - * unsupported. - */ - @Test - void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) { - ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( - wm.getHttpBaseUrl(), - "wrong-service-type", - TEST_USER, TEST_PASS, - false); - - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, - String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage()); - } - - /** - * Tests the succesfull persistance of Homebox credential response to the - * database. - * - * @param wm the WiremockRuntimeInfo object - */ - @Test - void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) { - - stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue())) - .willReturn(okJson(okJsonHomeboxResponse))); - - ConnectionRequest request = connectionRequest(wm); - - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - - ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); - - assertThat(dbEntry).isNotNull(); - assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); - assertThat(dbEntry.getUsername()).isEqualTo(request.username()); - - if (dbEntry instanceof HomeboxEntity hbE) { - assertThat(hbE.getToken()).isEqualTo("fake-jwt-token"); - assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach"); - assertThat(hbE.getExpiresAt().toString()).hasToString("2026-04-26T02:23:13Z"); + String expiresAt = documentContext.read("$.expiresAt", String.class); + assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } - } - @Test - void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) { - ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, - null, - false); + /** + * Tests the Unauthorized custom exception. + * + * @param wm the WiremockRuntimeInfo object + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { - ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, - String.class); + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); - } + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + connectionRequest(wm), String.class); - /** - * Creates a valid connection request with a mock Api through - * WireMockRuntimeInfo. - * - * @param wm the WiremockRuntimeInfo object - * @return a mock api connection request. - */ - private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { - return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, TEST_PASS, - null); - } + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage()); + } + + /** + * Tests a server error from the external api. + * + * @param wm the WiremockRuntimeInfo object + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { + + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + connectionRequest(wm), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage()); + } + + /** + * Checks when the service is unavailable or the app URL is wrong. + */ + @Test + void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) { + + stubFor(post(HOMEBOX_LOGIN.getValue()) + .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); + + ConnectionRequest badRequest = connectionRequest(wm); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + badRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()) + .contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); + } + + /** + * Checks if any login fields are empty since all of them are mandatory. + */ + @Test + void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() { + + ConnectionRequest emtpyRequest = new ConnectionRequest("", null, "", "", false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + emtpyRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); + } + + /** + * Test the exception when there is an input for serviceType but it's unsupported. + */ + @Test + void shouldReturnBadRequestWhenServiceTypeIsInvalid(WireMockRuntimeInfo wm) { + String body = """ + {"appUrl":"%s","serviceType":"wrong-service-type","username":"%s","password":"%s"} + """ + .formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange(LOGIN.getValue(), + HttpMethod.POST, entity, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + /** + * Tests the succesfull persistance of Homebox credential response to the database. + * + * @param wm the WiremockRuntimeInfo object + */ + @Test + void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) { + + stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue())) + .willReturn(okJson(okJsonHomeboxResponse))); + + ConnectionRequest request = connectionRequest(wm); + + ResponseEntity response = + restTemplate.postForEntity(LOGIN.getValue(), request, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), + request.username()); + + assertThat(dbEntry).isNotNull(); + assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); + assertThat(dbEntry.getUsername()).isEqualTo(request.username()); + + if (dbEntry instanceof HomeboxEntity hbE) { + assertThat(hbE.getToken()).isEqualTo("fake-jwt-token"); + assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach"); + assertThat(hbE.getExpiresAt().toString()) + .hasToString("2026-04-26T02:23:13Z"); + } + } + + @Test + void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) { + ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), + HOMEBOX, MOCK_USER, null, false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), + missingCredentials, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); + } + + /** + * Creates a valid connection request with a mock Api through WireMockRuntimeInfo. + * + * @param wm the WiremockRuntimeInfo object + * @return a mock api connection request. + */ + private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { + return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX, MOCK_USER, MOCK_PASS, + null); + } } diff --git a/backend/src/test/java/com/vaessl/app/connection/Mockdata.java b/backend/src/test/java/com/vaessl/app/connection/Mockdata.java deleted file mode 100644 index 657e15c..0000000 --- a/backend/src/test/java/com/vaessl/app/connection/Mockdata.java +++ /dev/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"; -} diff --git a/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java b/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java new file mode 100644 index 0000000..63dd067 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java @@ -0,0 +1,37 @@ +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; +import static com.vaessl.app.shared.ServiceType.HOMEBOX; + + +@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)); + } +} diff --git a/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java new file mode 100644 index 0000000..6ba4d18 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java @@ -0,0 +1,148 @@ +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.shared.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, + "parent": { + "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 shouldReturnBadRequestWhenServiceTypeIsInvalid() throws Exception { + mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON) + .content(searchRequestBody("INVALID_SERVICETYPE"))) + .andExpect(status().isBadRequest()); + } + + private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) { + return searchRequestBody(wm.getHttpBaseUrl(), serviceType); + } + + private String searchRequestBody(String serviceType) { + return searchRequestBody("http://irrelevant", serviceType); + } + + private String searchRequestBody(String appUrl, String serviceType) { + return """ + { + "appUrl": "%s", + "query": "Item", + "serviceType": "%s", + "username": "%s" + } + """.formatted(appUrl, serviceType, MOCK_USER); + } + + private String connectionRequestBody(WireMockRuntimeInfo wm) { + return """ + { + "appUrl": "%s", + "serviceType": "HOMEBOX", + "username": "%s", + "password": "%s" + } + """.formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS); + } +} diff --git a/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java b/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java new file mode 100644 index 0000000..0970e25 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/search/SearchResponseTest.java @@ -0,0 +1,34 @@ +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_ID, MOCK_TITLE, MOCK_DESCRIPTION, null); + + assertThat(response.getExtra(null)).isNull(); + } + + @Test + void shouldReturnNullWhenExtraDataKeyIsMissing() { + + SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value")); + + assertThat(response.getExtra("missing")).isNull(); + } + + @Test + void shouldReturnExtraDataValue() { + + SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value")); + + assertThat(response.id()).isEqualTo(MOCK_ID); + assertThat(response.getExtra("key")).contains("value"); + + } +} diff --git a/docs/02-Preparation/07-claude-code-feasibility.md b/docs/02-Preparation/07-claude-code-feasibility.md index 5a093cb..b6e142d 100644 --- a/docs/02-Preparation/07-claude-code-feasibility.md +++ b/docs/02-Preparation/07-claude-code-feasibility.md @@ -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. --- - **connection/HomeBoxConnectionProvider.java (modified)** + **connection/HomeboxConnectionProvider.java (modified)** Implements the new interface methods: - checkCredentials validates that username and password are present before touching the network. diff --git a/docs/03-Architecture/01-Login-Architecture.md b/docs/03-Architecture/01-Login-Architecture.md index fcf3dc5..93f583e 100644 --- a/docs/03-Architecture/01-Login-Architecture.md +++ b/docs/03-Architecture/01-Login-Architecture.md @@ -94,11 +94,11 @@ public interface ConnectionProvider { - `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*** +***HomeboxConnectionProvider.java*** ```java @Component -public class HomeBoxConnectionProvider implements ConnectionProvider { +public class HomeboxConnectionProvider implements ConnectionProvider { @Override public String getServiceType() { return "HOMEBOX"; } diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..cf33cf6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + Vaessl ~ AI Bridge
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c30b522..44a492a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import { Dashboard } from './components/Dashboard' +import { Dashboard } from './components/connections/Dashboard' function App() { return ( diff --git a/frontend/src/api/connections.ts b/frontend/src/api/connections.ts index 633e063..a341377 100644 --- a/frontend/src/api/connections.ts +++ b/frontend/src/api/connections.ts @@ -1,5 +1,5 @@ import { apiFetch } from './client' -import type { AuthResponse, ConnectionStatus, LoginRequest } from '../types/connection' +import type { AuthResponse, ConnectionStatus, LoginRequest, ServiceType } from '../types/connection' export const login = (req: LoginRequest) => apiFetch('/login', { method: 'POST', body: JSON.stringify(req) }) @@ -7,5 +7,5 @@ export const login = (req: LoginRequest) => export const getStatuses = () => apiFetch('/connections/status') -export const logout = (serviceType: string) => +export const logout = (serviceType: ServiceType) => apiFetch(`/connections/${serviceType}`, { method: 'DELETE' }) diff --git a/frontend/src/api/searches.ts b/frontend/src/api/searches.ts new file mode 100644 index 0000000..18aaa08 --- /dev/null +++ b/frontend/src/api/searches.ts @@ -0,0 +1,8 @@ +import { apiFetch } from "./client"; +import type { PagedSearchResponse, SearchRequest, SearchResponse } from "../types/search"; + +export const search = (req: SearchRequest) => + apiFetch>("/search", { + method: "POST", + body: JSON.stringify(req), + }); diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx deleted file mode 100644 index b75a625..0000000 --- a/frontend/src/components/Dashboard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useState, useEffect } from "react" -import { getStatuses, logout } from "../api/connections" -import type { ConnectionStatus } from "../types/connection" -import { ServiceCard } from "./ServiceCard" -import { ConnectModal } from "./ConnectModal" -import "./Dashboard.scss" - -const SERVICES = [ - { serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' }, -] as const - -export function Dashboard() { - const [statuses, setStatuses] = useState([]) - const [openModal, setOpenModal] = useState(null) - - const refresh = () => { - getStatuses().then(setStatuses).catch(() => { }) - } - - useEffect(() => { refresh() }, []) - - const handleDisconnect = (serviceType: string) => { - logout(serviceType).then(refresh).catch(() => { }) - } - - const activeModal = SERVICES.find(s => s.serviceType === openModal) - - return ( -
-
-

Vaessl Dashboard

-
- -

Services

-
- {SERVICES.map(({ serviceType, label, icon }) => ( - s.serviceType === serviceType) ?? null} - onConnect={() => setOpenModal(serviceType)} - onDisconnect={() => handleDisconnect(serviceType)} - /> - ))} -
- - {activeModal && ( - setOpenModal(null)} - onSuccess={() => { setOpenModal(null); refresh() }} - /> - )} -
- ) -} \ No newline at end of file diff --git a/frontend/src/components/ConnectModal.tsx b/frontend/src/components/connections/ConnectModal.tsx similarity index 94% rename from frontend/src/components/ConnectModal.tsx rename to frontend/src/components/connections/ConnectModal.tsx index 692d74b..c6b26f0 100644 --- a/frontend/src/components/ConnectModal.tsx +++ b/frontend/src/components/connections/ConnectModal.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState, type SyntheticEvent } from 'react' -import { login } from '../api/connections' -import type { LoginRequest } from '../types/connection' -import './ConnectModal.scss' +import { login } from '../../api/connections' +import type { LoginRequest, ServiceType } from '../../types/connection' +import '../ui/Modal.scss' interface Props { - serviceType: string + serviceType: ServiceType label: string onClose: () => void onSuccess: () => void diff --git a/frontend/src/components/Dashboard.scss b/frontend/src/components/connections/Dashboard.scss similarity index 100% rename from frontend/src/components/Dashboard.scss rename to frontend/src/components/connections/Dashboard.scss diff --git a/frontend/src/components/connections/Dashboard.tsx b/frontend/src/components/connections/Dashboard.tsx new file mode 100644 index 0000000..0d2832e --- /dev/null +++ b/frontend/src/components/connections/Dashboard.tsx @@ -0,0 +1,75 @@ +import { useState, useEffect } from "react" +import { getStatuses, logout } from "../../api/connections" +import { ServiceType, type ConnectionStatus } from "../../types/connection" +import { ServiceCard } from "./ServiceCard" +import { ConnectModal } from "./ConnectModal" +import "./Dashboard.scss" +import { SearchModal } from "../search/SearchModal" + +const SERVICES = [ + { serviceType: ServiceType.HOMEBOX, label: 'Homebox', icon: '📦' }, +] as const + +export function Dashboard() { + const [statuses, setStatuses] = useState([]) + const [openConnectionModal, setOpenConnectionModal] = useState(null) + const [openSearchModal, setOpenSearchModal] = useState(null) + + const refresh = () => { + getStatuses().then(setStatuses).catch(() => { }) + } + + useEffect(() => { refresh() }, []) + + const handleDisconnect = (serviceType: ServiceType) => { + logout(serviceType).then(refresh).catch(() => { }) + } + + const activeConnectionModal = SERVICES.find(s => s.serviceType === openConnectionModal) + const activeSearchModal = SERVICES.find(s => s.serviceType === openSearchModal) + const activeSearchStatus = statuses.find(s => s.serviceType === openSearchModal) + + return ( +
+
+

Vaessl Dashboard

+
+ +

Services

+
+ {SERVICES.map(({ serviceType, label, icon }) => ( + s.serviceType === serviceType) ?? null} + onConnect={() => setOpenConnectionModal(serviceType)} + onDisconnect={() => handleDisconnect(serviceType)} + onSearch={() => setOpenSearchModal(serviceType)} + /> + ))} +
+ + {activeConnectionModal && ( + setOpenConnectionModal(null)} + onSuccess={() => { setOpenConnectionModal(null); refresh() }} + /> + )} + + {activeSearchModal && ( + setOpenSearchModal(null) } + + /> + )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ServiceCard.scss b/frontend/src/components/connections/ServiceCard.scss similarity index 68% rename from frontend/src/components/ServiceCard.scss rename to frontend/src/components/connections/ServiceCard.scss index b35c55c..bab31b0 100644 --- a/frontend/src/components/ServiceCard.scss +++ b/frontend/src/components/connections/ServiceCard.scss @@ -81,39 +81,4 @@ display: flex; gap: 8px; } - - &__btn { - font-size: 14px; - font-weight: 500; - padding: 7px 16px; - border-radius: 6px; - border: 1px solid transparent; - cursor: pointer; - transition: box-shadow 0.2s, opacity 0.2s; - - &:hover { - opacity: 0.85; - } - - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - - &--connect { - background: var(--accent); - color: #fff; - } - - &--disconnect { - background: transparent; - color: var(--text); - border-color: var(--border); - - &:hover { - border-color: #ef4444; - color: #ef4444; - } - } - } } \ No newline at end of file diff --git a/frontend/src/components/ServiceCard.tsx b/frontend/src/components/connections/ServiceCard.tsx similarity index 69% rename from frontend/src/components/ServiceCard.tsx rename to frontend/src/components/connections/ServiceCard.tsx index 3b1bc5d..e71b9fd 100644 --- a/frontend/src/components/ServiceCard.tsx +++ b/frontend/src/components/connections/ServiceCard.tsx @@ -1,16 +1,18 @@ -import type { ConnectionStatus } from '../types/connection' +import type { ConnectionStatus, ServiceType } from '../../types/connection' +import { ActionButton } from '../ui/ActionButton' import './ServiceCard.scss' interface Props { - serviceType: string + serviceType: ServiceType label: string icon: string status: ConnectionStatus | null onConnect: () => void onDisconnect: () => void + onSearch: () => void } -export function ServiceCard({ serviceType: _serviceType, label, icon, status, onConnect, onDisconnect }: Readonly) { +export function ServiceCard({ serviceType: _serviceType, label, icon, status, onConnect, onDisconnect, onSearch }: Readonly) { const connected = status?.connected ?? false const formatExpiry = (iso: string | null) => { @@ -37,15 +39,10 @@ export function ServiceCard({ serviceType: _serviceType, label, icon, status, on
- {connected ? ( - - ) : ( - - )} + + {connected ? 'Disconnect' : 'Connect'} + + {connected && (Search)}
) diff --git a/frontend/src/components/search/SearchModal.tsx b/frontend/src/components/search/SearchModal.tsx new file mode 100644 index 0000000..8d22c4e --- /dev/null +++ b/frontend/src/components/search/SearchModal.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState, type SyntheticEvent } from 'react' +import '../ui/Modal.scss' +import { type PagedSearchResponse, type SearchResponse, type SearchRequest } from '../../types/search' +import { search } from '../../api/searches' +import type { ServiceType } from '../../types/connection' + +interface Props { + serviceType: ServiceType + label: string + appUrl: string + username: string + onClose: () => void +} + +export function SearchModal({ serviceType, label, appUrl, username, onClose }: Readonly) { + const [query, setQuery] = useState('') + const [results, setResults] = useState | null>(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const firstInputRef = useRef(null) + + const dialogRef = useRef(null) + + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + dialog.showModal() + firstInputRef.current?.focus() + + const handleCancel = (e: Event) => { + e.preventDefault() + onClose() + } + dialog.addEventListener('cancel', handleCancel) + return () => dialog.removeEventListener('cancel', handleCancel) + }, [onClose]) + + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + try { + const req: SearchRequest = { appUrl, serviceType, username, query} + const res = await search(req) + console.log(req) + setResults(res) + console.log('results', results) + } catch (err) { + setError(err instanceof Error ? err.message : 'Search failed') + } finally { + setLoading(false) + } + } + + return ( + +
+ + +
+
+
+ setQuery(e.target.value)} /> +
+ {error &&

{error}

} + +
+ {results && ( +
+

{results.totalElements}

+ {results.content.map((item) => (
+

{item.title}

+ {item.description &&

{item.description}

} + +
))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/ui/ActionButton.scss b/frontend/src/components/ui/ActionButton.scss new file mode 100644 index 0000000..6a71384 --- /dev/null +++ b/frontend/src/components/ui/ActionButton.scss @@ -0,0 +1,35 @@ +.action-btn { + font-size: 14px; + font-weight: 500; + padding: 7px 16px; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + transition: box-shadow 0.2s, opacity 0.2s; + + &:hover { + opacity: 0.85; + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + &--connect, + &--search { + background: var(--accent); + color: #fff; + } + + &--disconnect { + background: transparent; + color: var(--text); + border-color: var(--border); + + &:hover { + border-color: #ef4444; + color: #ef4444; + } + } +} diff --git a/frontend/src/components/ui/ActionButton.tsx b/frontend/src/components/ui/ActionButton.tsx new file mode 100644 index 0000000..37c54b8 --- /dev/null +++ b/frontend/src/components/ui/ActionButton.tsx @@ -0,0 +1,17 @@ +import './ActionButton.scss' + +type Variant = 'connect' | 'disconnect' | 'search' + +interface Props { + variant: Variant + onClick: () => void + children: React.ReactNode +} + +export function ActionButton({ variant, onClick, children }: Readonly) { + return ( + + ) +} diff --git a/frontend/src/components/ConnectModal.scss b/frontend/src/components/ui/Modal.scss similarity index 72% rename from frontend/src/components/ConnectModal.scss rename to frontend/src/components/ui/Modal.scss index b816391..0bdd995 100644 --- a/frontend/src/components/ConnectModal.scss +++ b/frontend/src/components/ui/Modal.scss @@ -17,6 +17,8 @@ width: 100%; max-width: 420px; box-shadow: var(--shadow); + display: flex; + flex-direction: column; &__header { display: flex; @@ -87,7 +89,7 @@ font-size: 13px; color: #ef4444; padding: 8px 12px; - background: rgba(239, 68, 68, 0.08); + background: rgba(11, 0, 0, 0.499); border-radius: 6px; border: 1px solid rgba(239, 68, 68, 0.2); } @@ -110,4 +112,44 @@ &:disabled { opacity: 0.6; cursor: not-allowed; } &:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } } + + &--expanded { + max-height: 70vh; + } + + &__results { + overflow-y: auto; + flex: 1; + min-height: 0; // required: flex children won't shrink without this + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 8px; + } + + &__results-count { + font-size: 12px; + color: var(--text); + margin: 0 0 8px 0; + } + + &__result-item { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + } + + &__result-title { + font-weight: 500; + font-size: 14px; + color: var(--text-h); + margin: 0; + } + + &__result-desc { + font-size: 13px; + color: var(--text); + margin: 4px 0 0 0; + } } diff --git a/frontend/src/types/connection.ts b/frontend/src/types/connection.ts index 1e77aab..808440d 100644 --- a/frontend/src/types/connection.ts +++ b/frontend/src/types/connection.ts @@ -1,20 +1,26 @@ +export const ServiceType = { + HOMEBOX: "HOMEBOX", +} as const; + +export type ServiceType = (typeof ServiceType)[keyof typeof ServiceType]; + export interface LoginRequest { - appUrl: string - serviceType: string - username: string - password: string - stayLoggedIn: boolean + appUrl: string; + serviceType: ServiceType; + username: string; + password: string; + stayLoggedIn: boolean; } export interface AuthResponse { - serviceType: string - expiresAt: string + serviceType: ServiceType; + expiresAt: string; } export interface ConnectionStatus { - serviceType: string - appUrl: string - username: string - expiresAt: string | null - connected: boolean + serviceType: ServiceType; + appUrl: string; + username: string; + expiresAt: string | null; + connected: boolean; } diff --git a/frontend/src/types/search.ts b/frontend/src/types/search.ts new file mode 100644 index 0000000..6b1888e --- /dev/null +++ b/frontend/src/types/search.ts @@ -0,0 +1,25 @@ +import type { ServiceType } from "./connection"; + +export interface SearchRequest { + appUrl: string + serviceType: ServiceType + username: string + query: string | null +} + +export interface SearchResponse { + id: string + title: string + description: string | null + extraData: Record +} + +export interface PagedSearchResponse { + content: T[] + page: number + pageSize: number + totalElements: number + first: boolean + last: boolean + sort: string +}