Compare commits
49
Commits
f0d536d8f4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b08fbf3b03 | ||
|
|
51382d0bd1 | ||
|
|
a55270f6d5 | ||
|
|
7f6fd4259b | ||
|
|
46a86f15d8 | ||
|
|
78b6e04db6 | ||
|
|
3fed8a4ea2 | ||
|
|
5b0c2d6d02 | ||
|
|
ee2685aa07 | ||
|
|
12fc7909fd | ||
|
|
9bbf2fd098 | ||
|
|
9c2cbd1608 | ||
|
|
fffdd2a575 | ||
|
|
8c18d7ff73 | ||
|
|
4318fc13f6 | ||
|
|
a2e110d3dd | ||
|
|
adb51584e7 | ||
|
|
048120688f | ||
|
|
b356682175 | ||
|
|
bdbfece62c | ||
|
|
597c941994 | ||
|
|
8183bf61ea | ||
|
|
5838084b73 | ||
|
|
77ee6f68c6 | ||
|
|
5c58fe43d9 | ||
|
|
7652645f42 | ||
|
|
2ea346651f | ||
|
|
6b5a3b5687 | ||
|
|
68bc14198e | ||
|
|
33c560555e | ||
|
|
ff9cfe2b3c | ||
|
|
17e15279c5 | ||
|
|
40cbe7103f | ||
|
|
fb64a7787f | ||
|
|
38c72d1bb8 | ||
|
|
077308b547 | ||
|
|
6e3d3347e2 | ||
|
|
a1ce085123 | ||
|
|
61d399761d | ||
|
|
17a959e7a7 | ||
|
|
b7bc8f5525 | ||
|
|
a542d23c00 | ||
|
|
fad016cdaf | ||
|
|
dbf7a9c50d | ||
|
|
77bc8b996b | ||
|
|
f3fe9901c5 | ||
|
|
c461aa81cc | ||
|
|
856fa9e166 | ||
|
|
7ce01dff0b |
@@ -0,0 +1,61 @@
|
||||
name: SonarQube Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Build and Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18.4
|
||||
env:
|
||||
POSTGRES_USER: vaessl
|
||||
POSTGRES_PASSWORD: vaessl
|
||||
POSTGRES_DB: vaessl_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "zulu" # Alternative distribution options are available.
|
||||
|
||||
- name: Make Gradle Executable
|
||||
run: chmod +x ./gradlew
|
||||
working-directory: backend
|
||||
|
||||
- name: Build and Analyze
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
|
||||
DB_URL: jdbc:postgresql://postgres:5432/vaessl_test
|
||||
DB_TEST_URL: jdbc:postgresql://postgres:5432/vaessl_test
|
||||
DB_USERNAME: vaessl
|
||||
DB_PASSWORD: vaessl
|
||||
PG_DRIVER_CLASS_NAME: org.postgresql.Driver
|
||||
|
||||
OPENAI_BASE_URL: https://api.openai.com/v1
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
ALLOWED_ORIGINS: http://localhost:3000
|
||||
run: ./gradlew build sonar --info
|
||||
working-directory: backend
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,7 +1,11 @@
|
||||
val wiremockVersion = "3.12.0"
|
||||
|
||||
plugins {
|
||||
java
|
||||
id("org.springframework.boot") version "4.0.6"
|
||||
jacoco
|
||||
id("org.springframework.boot") version "4.1.0"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
id("org.sonarqube") version "7.3.0.8198"
|
||||
}
|
||||
|
||||
group = "com.vaessl"
|
||||
@@ -13,6 +17,15 @@ java {
|
||||
}
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "Vaessl")
|
||||
property("sonar.projectName", "Vaessl")
|
||||
property("sonar.coverage.jacoco.xmlReportPaths",
|
||||
"${layout.buildDirectory.get()}/reports/jacoco/test/jacocoTestReport.xml")
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom(configurations.annotationProcessor.get())
|
||||
@@ -23,7 +36,8 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
extra["springAiVersion"] = "2.0.0-M3"
|
||||
extra["springAiVersion"] = "2.0.0"
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
@@ -32,17 +46,24 @@ dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
||||
implementation("org.springframework.ai:spring-ai-starter-model-openai")
|
||||
|
||||
compileOnly("org.projectlombok:lombok")
|
||||
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
|
||||
// testImplementation("org.springframework.boot:spring-boot-starter-security-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testImplementation("org.wiremock:wiremock-standalone:3.12.0")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")}
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
@@ -55,5 +76,20 @@ tasks.withType<JavaCompile> {
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
useJUnitPlatform()
|
||||
finalizedBy(tasks.jacocoTestReport)
|
||||
testLogging {
|
||||
events("failed", "standardError")
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
showExceptions = true
|
||||
showCauses = true
|
||||
showStackTraces = true
|
||||
}
|
||||
}
|
||||
|
||||
tasks.jacocoTestReport {
|
||||
dependsOn(tasks.withType<Test>())
|
||||
reports {
|
||||
xml.required = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,7 @@ package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record AuthResponse(String serviceType, Instant expiresAt) {
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
public record AuthResponse(ServiceType serviceType, Instant expiresAt) {
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ 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 {
|
||||
@@ -53,19 +56,24 @@ public class ConnectionController {
|
||||
Collections.list(session.getAttributeNames()).stream()
|
||||
.filter(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX))
|
||||
.forEach(k -> {
|
||||
String serviceType = k.replace(SessionKeys.CONNECTION_ID_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<Void> logout(@PathVariable("serviceType") String serviceType,
|
||||
public ResponseEntity<Void> logout(@PathVariable("serviceType") ServiceType serviceType,
|
||||
HttpServletRequest httpReq) {
|
||||
|
||||
HttpSession session = httpReq.getSession(false);
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import com.vaessl.app.shared.ServiceProvider;
|
||||
|
||||
public interface ConnectionProvider extends ServiceProvider {
|
||||
|
||||
void checkCredentials(ConnectionRequest request);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface ConnectionRepository extends JpaRepository<ConnectionEntity, Long> {
|
||||
|
||||
ConnectionEntity findByAppUrlAndUsername(String appUrl, String username);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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,
|
||||
@NotBlank(message = "Service type is mandatory") String serviceType,
|
||||
@NotNull(message = "Service type is mandatory") ServiceType serviceType,
|
||||
String username, String password, String apiKey,
|
||||
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
|
||||
|
||||
@@ -15,7 +17,7 @@ public record ConnectionRequest(@NotBlank(message = "App URL is mandatory") Stri
|
||||
}
|
||||
}
|
||||
|
||||
public ConnectionRequest(String appUrl, String serviceType, String username,
|
||||
public ConnectionRequest(String appUrl, ServiceType serviceType, String username,
|
||||
String password, Boolean stayLoggedIn) {
|
||||
this(appUrl, serviceType, username, password, null, stayLoggedIn);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
@Service
|
||||
public class ConnectionService {
|
||||
|
||||
private final Map<String, ConnectionProvider> providerRegistry;
|
||||
private final Map<ServiceType, ConnectionProvider> providerRegistry;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
|
||||
this.providerRegistry = providers.stream()
|
||||
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
|
||||
Map<ServiceType, ConnectionProvider> registry = new EnumMap<>(ServiceType.class);
|
||||
for (ConnectionProvider provider : providers) {
|
||||
registry.put(provider.getServiceType(), provider);
|
||||
}
|
||||
this.providerRegistry = registry;
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
|
||||
@@ -48,7 +52,8 @@ 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;
|
||||
@@ -57,7 +62,7 @@ public class ConnectionService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import org.springframework.web.client.RestClient;
|
||||
|
||||
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 {
|
||||
@@ -44,8 +45,8 @@ public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getServiceType() {
|
||||
return "HOMEBOX";
|
||||
public ServiceType getServiceType() {
|
||||
return ServiceType.HOMEBOX;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
public interface ServiceProvider {
|
||||
|
||||
/**
|
||||
* Returns the service type key used to look up this provider in a registry, e.g.
|
||||
* {@code "HOMEBOX"}.
|
||||
*/
|
||||
String getServiceType();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
public final class SessionKeys {
|
||||
|
||||
static final String CONNECTION_ID_SUFFIX = "_CONNECTION_ID";
|
||||
|
||||
private SessionKeys() {}
|
||||
|
||||
public static String connectionId(String serviceType) {
|
||||
return serviceType + CONNECTION_ID_SUFFIX;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
@@ -24,7 +23,7 @@ public class GlobalExceptionHandler {
|
||||
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
|
||||
|
||||
String defaultMessages = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(FieldError::getDefaultMessage).collect(Collectors.joining(", "));
|
||||
.map(field -> field.getDefaultMessage()).collect(Collectors.joining(", "));
|
||||
|
||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
|
||||
|
||||
@@ -14,8 +14,9 @@ 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.connection.Endpoint.*;
|
||||
import static com.vaessl.app.shared.Endpoint.*;
|
||||
|
||||
@Component
|
||||
public class HomeboxSearchProvider implements SearchProvider {
|
||||
@@ -31,8 +32,8 @@ public class HomeboxSearchProvider implements SearchProvider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getServiceType() {
|
||||
return "HOMEBOX";
|
||||
public ServiceType getServiceType() {
|
||||
return ServiceType.HOMEBOX;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -58,10 +59,11 @@ public class HomeboxSearchProvider implements SearchProvider {
|
||||
}
|
||||
|
||||
List<SearchResponse> items = hbResponse.items().stream().map(i -> {
|
||||
String id = i.id();
|
||||
String title = i.name();
|
||||
String description = i.description();
|
||||
Map<String, Object> extraSearchResponseData = Map.of("location", i.parent());
|
||||
return new SearchResponse(title, description, extraSearchResponseData);
|
||||
return new SearchResponse(id, title, description, extraSearchResponseData);
|
||||
}).toList();
|
||||
|
||||
return new PageImpl<>(items, pageable, hbResponse.total());
|
||||
@@ -71,7 +73,7 @@ public class HomeboxSearchProvider implements SearchProvider {
|
||||
List<HomeboxItem> items) {
|
||||
}
|
||||
|
||||
private record HomeboxItem(String name, String description, HomeboxLocation parent) {
|
||||
private record HomeboxItem(String id, String name, String description, HomeboxLocation parent) {
|
||||
}
|
||||
|
||||
private record HomeboxLocation(String name, String description) {
|
||||
|
||||
@@ -9,7 +9,7 @@ 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.connection.SessionKeys;
|
||||
import com.vaessl.app.shared.SessionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.vaessl.app.search;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import com.vaessl.app.connection.ServiceProvider;
|
||||
import com.vaessl.app.shared.ServiceProvider;
|
||||
|
||||
/**
|
||||
* Implemented by any service that supports querying its remote API for items.
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
* @param pageable the Pageable interface
|
||||
* @return a list of Page<SearchResponse> items matching the query
|
||||
*/
|
||||
Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable);
|
||||
|
||||
@@ -1,7 +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,
|
||||
@NotBlank String serviceType) {
|
||||
@NotNull ServiceType serviceType) {
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ package com.vaessl.app.search;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record SearchResponse(String title, String description, Map<String, Object> extraData) {
|
||||
public record SearchResponse(String id, String title, String description,
|
||||
Map<String, Object> extraData) {
|
||||
|
||||
public String getExtra(String key) {
|
||||
if (extraData == null) {
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import java.util.EnumMap;
|
||||
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<String, SearchProvider> providerRegistry;
|
||||
private final Map<ServiceType, SearchProvider> providerRegistry;
|
||||
|
||||
public SearchService(List<SearchProvider> providers) {
|
||||
this.providerRegistry = Map.copyOf(providers.stream()
|
||||
.collect(Collectors.toMap(SearchProvider::getServiceType, p -> p)));
|
||||
Map<ServiceType, SearchProvider> registry = new EnumMap<>(ServiceType.class);
|
||||
for (SearchProvider provider : providers) {
|
||||
registry.put(provider.getServiceType(), provider);
|
||||
}
|
||||
this.providerRegistry = registry;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.vaessl.app.connection;
|
||||
package com.vaessl.app.shared;
|
||||
|
||||
public enum Endpoint {
|
||||
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS(
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.vaessl.app.shared;
|
||||
|
||||
public interface ServiceProvider {
|
||||
|
||||
ServiceType getServiceType();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.vaessl.app.shared;
|
||||
|
||||
public enum ServiceType {
|
||||
HOMEBOX;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ spring:
|
||||
base-url: ${OPENAI_BASE_URL}
|
||||
api-key: ${OPENAI_KEY}
|
||||
chat:
|
||||
options:
|
||||
model: gpt-4o-mini
|
||||
model: gpt-4o-mini
|
||||
session:
|
||||
store-type: jdbc
|
||||
jdbc:
|
||||
|
||||
@@ -1,13 +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 String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.vaessl.app.connection;
|
||||
|
||||
import static com.vaessl.app.Mockdata.*;
|
||||
|
||||
import static com.vaessl.app.connection.Endpoint.*;
|
||||
import static com.vaessl.app.connection.ServiceType.*;
|
||||
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.*;
|
||||
|
||||
@@ -32,6 +32,7 @@ class ConnectionControllerTest {
|
||||
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 = """
|
||||
{
|
||||
@@ -66,11 +67,11 @@ class ConnectionControllerTest {
|
||||
.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].serviceType").value(HOMEBOX.name()))
|
||||
.andExpect(jsonPath("$[0].username").value(MOCK_USER))
|
||||
.andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl()))
|
||||
.andExpect(jsonPath("$[0].connected").value(true));
|
||||
@@ -87,10 +88,10 @@ class ConnectionControllerTest {
|
||||
.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].serviceType").value(HOMEBOX.name()))
|
||||
.andExpect(jsonPath("$[0].connected").value(false))
|
||||
.andExpect(jsonPath("$[0].expiresAt")
|
||||
.value("2000-01-01T00:00:00Z"));
|
||||
@@ -106,7 +107,7 @@ class ConnectionControllerTest {
|
||||
.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());
|
||||
@@ -122,7 +123,7 @@ class ConnectionControllerTest {
|
||||
.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())
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
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;
|
||||
|
||||
class HomeboxConnectionProviderTest {
|
||||
@@ -13,8 +14,8 @@ class HomeboxConnectionProviderTest {
|
||||
private final HomeboxConnectionProvider provider = new HomeboxConnectionProvider(null, null);
|
||||
|
||||
@Test
|
||||
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
|
||||
ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false);
|
||||
void checkCredentialsShouldThrowExceptionWhenFieldsAreMissing() {
|
||||
ConnectionRequest request = new ConnectionRequest(MOCK_URL, HOMEBOX, null, null, false);
|
||||
|
||||
EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> {
|
||||
provider.checkCredentials(request);
|
||||
|
||||
@@ -6,7 +6,11 @@ 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;
|
||||
@@ -19,184 +23,191 @@ import com.jayway.jsonpath.JsonPath;
|
||||
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"
|
||||
}
|
||||
""";
|
||||
|
||||
/**
|
||||
* 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<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
|
||||
ResponseEntity<String> 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<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a server error from the external api.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks when the service is unavailable or the app URL is wrong.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
|
||||
|
||||
ConnectionRequest badRequest = connectionRequest(wm);
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any login fields are empty since all of them are mandatory.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
|
||||
|
||||
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the exception when there is an input for serviceType but it's unsupported.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) {
|
||||
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(wm.getHttpBaseUrl(),
|
||||
"wrong-service-type", MOCK_USER, MOCK_PASS, false);
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the succesfull persistance of Homebox credential response to the database.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue()))
|
||||
.willReturn(okJson(okJsonHomeboxResponse)));
|
||||
|
||||
ConnectionRequest request = connectionRequest(wm);
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
|
||||
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(), MOCK_USER, null, false);
|
||||
/**
|
||||
* Tests the Unauthorized custom exception.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) {
|
||||
|
||||
ResponseEntity<String> 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<String> 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(), MOCK_USER, MOCK_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<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
connectionRequest(wm), String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks when the service is unavailable or the app URL is wrong.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
|
||||
|
||||
ConnectionRequest badRequest = connectionRequest(wm);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
badRequest, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(response.getBody())
|
||||
.contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any login fields are empty since all of them are mandatory.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
|
||||
|
||||
ConnectionRequest emtpyRequest = new ConnectionRequest("", null, "", "", false);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
emtpyRequest, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the exception when there is an input for serviceType but it's unsupported.
|
||||
*/
|
||||
@Test
|
||||
void 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<String> entity = new HttpEntity<>(body, headers);
|
||||
|
||||
ResponseEntity<String> 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<String> 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<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
missingCredentials, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid connection request with a mock Api 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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 {
|
||||
@@ -27,7 +29,7 @@ class HomeboxSearchProviderTest {
|
||||
|
||||
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
|
||||
|
||||
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "test query", "HOMEBOX");
|
||||
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "test query", HOMEBOX);
|
||||
Pageable pageable = PageRequest.of(0, 10);
|
||||
assertThrows(ConnectionNotFoundException.class,
|
||||
() -> provider.getSearchResults(request, pageable));
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.vaessl.app.search;
|
||||
|
||||
import static com.vaessl.app.Mockdata.MOCK_PASS;
|
||||
import static com.vaessl.app.Mockdata.MOCK_USER;
|
||||
import static com.vaessl.app.connection.Endpoint.*;
|
||||
import static com.vaessl.app.shared.Endpoint.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@@ -110,25 +110,21 @@ class SearchControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnUnauthorizedWhenSessionHasNoConnectionForRequestedServiceType(
|
||||
WireMockRuntimeInfo wm) throws Exception {
|
||||
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
|
||||
|
||||
MvcResult loginResult =
|
||||
mockMvc.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
mockMvc.perform(
|
||||
post(SEARCH_REQUEST).cookie(sessionCookie).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(searchRequestBody(wm, "OTHER_SERVICETYPE")))
|
||||
.andExpect(status().isUnauthorized());
|
||||
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",
|
||||
@@ -136,7 +132,7 @@ class SearchControllerTest {
|
||||
"serviceType": "%s",
|
||||
"username": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), serviceType, MOCK_USER);
|
||||
""".formatted(appUrl, serviceType, MOCK_USER);
|
||||
}
|
||||
|
||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||
|
||||
@@ -9,7 +9,7 @@ class SearchResponseTest {
|
||||
|
||||
@Test
|
||||
void shouldReturnNullWhenExtraDataIsNull() {
|
||||
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, null);
|
||||
SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, null);
|
||||
|
||||
assertThat(response.getExtra(null)).isNull();
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class SearchResponseTest {
|
||||
@Test
|
||||
void shouldReturnNullWhenExtraDataKeyIsMissing() {
|
||||
|
||||
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||
SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||
|
||||
assertThat(response.getExtra("missing")).isNull();
|
||||
}
|
||||
@@ -25,8 +25,9 @@ class SearchResponseTest {
|
||||
@Test
|
||||
void shouldReturnExtraDataValue() {
|
||||
|
||||
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||
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");
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
spring:
|
||||
datasource:
|
||||
url : ${DB_TEST_URL}
|
||||
url: ${DB_TEST_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
ddl-auto: update
|
||||
|
||||
@@ -36,8 +36,6 @@ Now deploy the docker-compose and check all the configurations:
|
||||
|
||||
```
|
||||
java --version
|
||||
mvn --version
|
||||
npm --version
|
||||
yarn --version
|
||||
nodejs --version
|
||||
```
|
||||
+37
-20
@@ -1,4 +1,4 @@
|
||||
**Vaessl: Spring Boot setup**
|
||||
**Vaessl: Spring Boot and database setup**
|
||||
|
||||
This app will use the current latest version 4.0.4 of Spring Boot and the latest OpenJDK 25 LTS.
|
||||
|
||||
@@ -138,7 +138,7 @@ spring:
|
||||
|
||||
```
|
||||
|
||||
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. The Docker Compose file will look something like this:
|
||||
The Docker Compose file for code-server will look something like this:
|
||||
|
||||
```
|
||||
---
|
||||
@@ -163,26 +163,43 @@ services:
|
||||
- 8124:8080
|
||||
- 5173:5173
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
vaessl-db:
|
||||
image: pgvector/pgvector:pg18
|
||||
container_name: vaessl-db
|
||||
environment:
|
||||
- POSTGRES_DB=vaessl
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=pw
|
||||
ports:
|
||||
- 5433:5432
|
||||
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. Just add databases via SQL or PgAdmin and install the pgvector extension to each database manually. There is an offical ready-made pgvector docker image but if you already host a PostGreSQL database you need to add the extension yourself.
|
||||
|
||||
vassal-test-db:
|
||||
image: pgvector/pgvector:pg18
|
||||
container_name: vassal-test-db
|
||||
environment:
|
||||
- POSTGRES_DB=vassal_test
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=pw
|
||||
ports:
|
||||
- 5434:5432
|
||||
Check the name of your PostGreSQL container:
|
||||
```
|
||||
docker ps
|
||||
```
|
||||
|
||||
Enter your container via bash:
|
||||
|
||||
```
|
||||
docker exec -it 876fb382969f bash
|
||||
```
|
||||
Before working on your database backup your databases:
|
||||
```
|
||||
su - postgres -c "pg_dumpall > /tmp/backup200526.sql"
|
||||
|
||||
#exit the container and copy the backup file to local file system
|
||||
docker cp 876fb382969f:/tmp/backup200526.sql .
|
||||
```
|
||||
|
||||
Install dependencies, build and install pgvector:
|
||||
apt-get update
|
||||
apt-get install -y build-essential git postgresql-server-dev-all
|
||||
```
|
||||
git clone https://github.com/pgvector/pgvector.git
|
||||
cd pgvector
|
||||
make
|
||||
make install
|
||||
docker restart 876fb382969f
|
||||
```
|
||||
Enter PostGreSQL container and create pgvector extension for each databse:
|
||||
```
|
||||
docker exec -it <container-name> psql -h localhost -U <db-user> -d <db-name>
|
||||
|
||||
CREATE EXTENSION vector;
|
||||
```
|
||||
|
||||
# Appendix: Additional config for developing in Code-Server
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>Vaessl ~ AI Bridge</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { Dashboard } from './components/Dashboard'
|
||||
import { Dashboard } from './components/connections/Dashboard'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
||||
@@ -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<AuthResponse>('/login', { method: 'POST', body: JSON.stringify(req) })
|
||||
@@ -7,5 +7,5 @@ export const login = (req: LoginRequest) =>
|
||||
export const getStatuses = () =>
|
||||
apiFetch<ConnectionStatus[]>('/connections/status')
|
||||
|
||||
export const logout = (serviceType: string) =>
|
||||
export const logout = (serviceType: ServiceType) =>
|
||||
apiFetch<void>(`/connections/${serviceType}`, { method: 'DELETE' })
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { apiFetch } from "./client";
|
||||
import type { PagedSearchResponse, SearchRequest, SearchResponse } from "../types/search";
|
||||
|
||||
export const search = (req: SearchRequest) =>
|
||||
apiFetch<PagedSearchResponse<SearchResponse>>("/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -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<ConnectionStatus[]>([])
|
||||
const [openModal, setOpenModal] = useState<string | null>(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 (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard__header">
|
||||
<h1 className="dashboard__title">Vaessl Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<p className="dashboard__section-label">Services</p>
|
||||
<div className="dashboard__cards">
|
||||
{SERVICES.map(({ serviceType, label, icon }) => (
|
||||
<ServiceCard
|
||||
key={serviceType}
|
||||
serviceType={serviceType}
|
||||
label={label}
|
||||
icon={icon}
|
||||
status={statuses.find(s => s.serviceType === serviceType) ?? null}
|
||||
onConnect={() => setOpenModal(serviceType)}
|
||||
onDisconnect={() => handleDisconnect(serviceType)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeModal && (
|
||||
<ConnectModal
|
||||
serviceType={activeModal.serviceType}
|
||||
label={activeModal.label}
|
||||
onClose={() => setOpenModal(null)}
|
||||
onSuccess={() => { setOpenModal(null); refresh() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -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
|
||||
@@ -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<ConnectionStatus[]>([])
|
||||
const [openConnectionModal, setOpenConnectionModal] = useState<string | null>(null)
|
||||
const [openSearchModal, setOpenSearchModal] = useState<string | null>(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 (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard__header">
|
||||
<h1 className="dashboard__title">Vaessl Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<p className="dashboard__section-label">Services</p>
|
||||
<div className="dashboard__cards">
|
||||
{SERVICES.map(({ serviceType, label, icon }) => (
|
||||
<ServiceCard
|
||||
key={serviceType}
|
||||
serviceType={serviceType}
|
||||
label={label}
|
||||
icon={icon}
|
||||
status={statuses.find(s => s.serviceType === serviceType) ?? null}
|
||||
onConnect={() => setOpenConnectionModal(serviceType)}
|
||||
onDisconnect={() => handleDisconnect(serviceType)}
|
||||
onSearch={() => setOpenSearchModal(serviceType)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeConnectionModal && (
|
||||
<ConnectModal
|
||||
serviceType={activeConnectionModal.serviceType}
|
||||
label={activeConnectionModal.label}
|
||||
onClose={() => setOpenConnectionModal(null)}
|
||||
onSuccess={() => { setOpenConnectionModal(null); refresh() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSearchModal && (
|
||||
<SearchModal
|
||||
serviceType={activeSearchModal.serviceType}
|
||||
label={activeSearchModal.label}
|
||||
appUrl={activeSearchStatus?.appUrl ?? ''}
|
||||
username={activeSearchStatus?.username ?? ''}
|
||||
onClose={() => setOpenSearchModal(null) }
|
||||
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-12
@@ -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<Props>) {
|
||||
export function ServiceCard({ serviceType: _serviceType, label, icon, status, onConnect, onDisconnect, onSearch }: Readonly<Props>) {
|
||||
const connected = status?.connected ?? false
|
||||
|
||||
const formatExpiry = (iso: string | null) => {
|
||||
@@ -37,15 +39,10 @@ export function ServiceCard({ serviceType: _serviceType, label, icon, status, on
|
||||
</div>
|
||||
</div>
|
||||
<div className="service-card__actions">
|
||||
{connected ? (
|
||||
<button className="service-card__btn service-card__btn--disconnect" onClick={onDisconnect}>
|
||||
Disconnect
|
||||
</button>
|
||||
) : (
|
||||
<button className="service-card__btn service-card__btn--connect" onClick={onConnect}>
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
<ActionButton variant={connected ? 'disconnect' : 'connect'} onClick={connected ? onDisconnect : onConnect}>
|
||||
{connected ? 'Disconnect' : 'Connect'}
|
||||
</ActionButton>
|
||||
{connected && (<ActionButton variant='search' onClick={onSearch}>Search</ActionButton>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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<Props>) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<PagedSearchResponse<SearchResponse> | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const firstInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const dialogRef = useRef<HTMLDialogElement>(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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<dialog className='modal' ref={dialogRef}>
|
||||
<div className='modal__header'>
|
||||
<h2 className='modal__title' id='modal-title'>Search in {label}</h2>
|
||||
<button className='modal__close' onClick={onClose} aria-label='Close'>×</button>
|
||||
</div>
|
||||
<form className='modal__form' onSubmit={handleSubmit}>
|
||||
<div className='modal__field'>
|
||||
<input id='search' className='modal__input'
|
||||
value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
{error && <p className='modal__error'>{error}</p>}
|
||||
<button className='modal__submit' type='submit' disabled={loading}>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
{results && (
|
||||
<div className='modal__results'>
|
||||
<p className='modal__results-count'>{results.totalElements}</p>
|
||||
{results.content.map((item) => (<div key={item.id} className='modal__result-item'>
|
||||
<p className='"modal__result-title'>{item.title}</p>
|
||||
{item.description && <p className='modal__result-desc'>{item.description}</p>}
|
||||
|
||||
</div>))}
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Props>) {
|
||||
return (
|
||||
<button className={`action-btn action-btn--${variant}`} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
export interface PagedSearchResponse<T> {
|
||||
content: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
totalElements: number
|
||||
first: boolean
|
||||
last: boolean
|
||||
sort: string
|
||||
}
|
||||
Reference in New Issue
Block a user