Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8184db0c46 | ||
|
|
65ff109800 | ||
|
|
48d120fbff | ||
|
|
43b6d227db | ||
|
|
fed981212a | ||
|
|
e310c1bbd8 | ||
|
|
9ae94c81e6 | ||
|
|
8f24163bc0 | ||
|
|
2ce90a6cac | ||
|
|
c363e16987 | ||
|
|
d5cf43ec08 | ||
|
|
6908f7530a | ||
|
|
573f0110c7 | ||
|
|
0508687cad | ||
|
|
baa0582d50 | ||
|
|
3086b7e2ca | ||
|
|
47466a6c61 | ||
|
|
28080c7954 | ||
|
|
23e4467e23 | ||
|
|
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 | ||
|
|
f0d536d8f4 | ||
|
|
5776676eeb | ||
|
|
1a86c23565 | ||
|
|
ea866377bc | ||
|
|
a7b984ca84 | ||
|
|
1ba85e129e | ||
|
|
c75bf2ad71 | ||
|
|
d91f39d087 | ||
|
|
f39bf049a0 | ||
|
|
5b2648d526 | ||
|
|
a8ecf65180 | ||
|
|
4d96524adb | ||
|
|
1d5006fd7e | ||
|
|
406a041ce9 | ||
|
|
d7233d817c | ||
|
|
92aaf63c12 | ||
|
|
ef09a3c84d |
@@ -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,47 +43,94 @@ Frontend (optional, defaults to `/api`):
|
|||||||
|
|
||||||
### Backend (`backend/src/main/java/com/vaessl/app/`)
|
### 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
|
- `POST /api/login` — authenticates a service, stores connection ID in session
|
||||||
- `GET /api/connections/status` — lists connected services for the current 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
|
- `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
|
||||||
|
- `POST /api/sync` — triggers a vector-store sync for the requested service; returns 401 if no active session. Not yet triggered on login or called from the frontend — manual/internal trigger only for now.
|
||||||
|
|
||||||
Four main modules:
|
Eight packages:
|
||||||
|
|
||||||
|
**`shared/`** — cross-cutting types used by more than one feature package
|
||||||
|
|
||||||
|
- `ServiceType` (enum): identifies each integrated app (e.g. `HOMEBOX`); used across `connection/`, `search/`, `sync/`
|
||||||
|
- `ServiceProvider` (interface): base for `ConnectionProvider`, `SearchProvider`, `SyncProvider`; declares `getServiceType()`
|
||||||
|
- `ServiceItem`: normalized item shape (`id`, `title`, `description`, `extraData`) returned by both search and sync fetches
|
||||||
|
- `Endpoint` (enum): API path constants for all external service calls
|
||||||
|
- `SessionKeys`: builds session attribute names of the form `{SERVICE_TYPE}_CONNECTION_ID`
|
||||||
|
|
||||||
**`config/`**
|
**`config/`**
|
||||||
|
|
||||||
- `CorsConfig`: env-driven allowed origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`); `allowCredentials(true)` is required for session cookies to work cross-origin
|
- `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`)
|
- `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
|
||||||
|
- `ConnectionIdentifiable` interface: `appUrl()`/`username()`/`serviceType()`; implemented by `SearchRequest` and `SyncRequest` so `HomeboxItemClient` can resolve the underlying connection regardless of which feature is calling it
|
||||||
- `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType`
|
- `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType`
|
||||||
- `ConnectionController`: stores `{serviceType}_CONNECTION_ID` in `HttpSession` after login; reads session attributes to build status responses
|
- `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` / `HomeboxConnectionEntity`: Homebox-specific implementation
|
||||||
|
|
||||||
**`dto/`** — `ConnectionRequest`, `LoginResult`, `AuthResponse`, `ConnectionStatusResponse`
|
**`homebox/`** — shared Homebox API access
|
||||||
|
|
||||||
|
- `HomeboxItemClient`: fetches a page of items from the Homebox entities API and maps them to `ServiceItem`; used by both `HomeboxSearchProvider` (keyword search) and `HomeboxSyncProvider` (vector-store sync) so the fetch/mapping logic isn't duplicated
|
||||||
|
|
||||||
|
**`vector/`** — vectorization
|
||||||
|
|
||||||
|
- `EmbeddingService`: embeds `ServiceItem`s into pgvector's `VectorStore` and prunes entries that no longer exist upstream. Document ID is `{connectionId}:{itemId}`, so re-syncing upserts existing items instead of duplicating them. Deliberately stateless (only field is the injected `VectorStore`) since it's a singleton bean — the per-sync ID accumulator used for pruning is owned by the caller (`HomeboxSyncProvider`), not held as instance state
|
||||||
|
- `PgVectorStore` itself is auto-configured by `spring-ai-starter-vector-store-pgvector` from `application.yaml` (`spring.ai.vectorstore.pgvector.dimensions`) — no manual config class in this codebase
|
||||||
|
|
||||||
|
**`search/`** — querying connected services (keyword and AI)
|
||||||
|
|
||||||
|
- `SearchProvider` interface: extends `ServiceProvider`; each integrated app implements `getSearchResults()`
|
||||||
|
- `SearchService`: dispatches by `ServiceType` via a single provider map — **does not yet route on `SearchRequest.aiSearch`**; `HomeboxSearchProvider` and `HomeboxAiSearchProvider` both currently register for `ServiceType.HOMEBOX`, so only one wins the registration (known gap, pending AI search work)
|
||||||
|
- `SearchController`: guards with session check before delegating to `SearchService`
|
||||||
|
- `SearchRequest`: includes `aiSearch: boolean` (not yet consumed, see above) and implements `ConnectionIdentifiable`
|
||||||
|
- `PagedSearchResponse`: includes nullable `summary` field — populated only for AI search results; null for keyword search
|
||||||
|
- `HomeboxSearchProvider`: keyword search; delegates the remote fetch to `HomeboxItemClient`
|
||||||
|
- `HomeboxAiSearchProvider`: **stub only** — `getSearchResults` throws `UnsupportedOperationException`; the actual similarity search + summary generation is pending
|
||||||
|
|
||||||
|
**`sync/`** — indexing connected services into the vector store
|
||||||
|
|
||||||
|
- `SyncProvider` interface: extends `ServiceProvider`; each integrated app implements `syncVectorStore()`
|
||||||
|
- `SyncService`: dispatches by `ServiceType` via a provider map, same pattern as `SearchService`
|
||||||
|
- `SyncController`: session-gated `POST /sync`; not currently called by the frontend or triggered on login
|
||||||
|
- `SyncRequest`: implements `ConnectionIdentifiable`
|
||||||
|
- `HomeboxSyncProvider`: pages through the full Homebox catalog via `HomeboxItemClient`, embedding each page through `EmbeddingService.vectorizeData` and collecting every item ID seen along the way. Once the full page loop finishes, calls `EmbeddingService.deleteStaleVectorEntries` **once** with the complete ID set, removing any previously indexed item no longer present upstream. Order matters — pruning per page instead of once at the end would treat items on other pages as stale and delete them too.
|
||||||
|
|
||||||
|
**Pending work (not yet implemented):** `HomeboxAiSearchProvider`'s actual similarity search + summary generation; `SearchService` routing two provider maps by `aiSearch`; triggering `/sync` on login or from the frontend; a frontend sync button and AI search UI.
|
||||||
|
|
||||||
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
|
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
|
||||||
|
|
||||||
### Frontend (`frontend/src/`)
|
### 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/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
|
- `api/connections.ts` — connection-specific API calls (`getStatuses`, `login`, `logout`)
|
||||||
- `types/connection.ts` — shared types: `LoginRequest`, `AuthResponse`, `ConnectionStatus`
|
- `api/searches.ts` — search API call (`search`)
|
||||||
- `components/Dashboard` — main view listing connected services
|
- `types/connection.ts` — `ServiceType`, `LoginRequest`, `AuthResponse`, `ConnectionStatus`
|
||||||
- `components/ConnectModal` — login form for adding a service connection
|
- `types/search.ts` — `SearchRequest`, `SearchResponse`, `PagedSearchResponse`
|
||||||
- `components/ServiceCard` — per-service status display
|
- `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
|
### Data & AI
|
||||||
|
|
||||||
- PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
|
- PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
|
||||||
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it
|
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it — `OPENAI_BASE_URL` points to LiteLLM, not OpenAI directly, keeping the underlying model provider configurable
|
||||||
|
- `spring-ai-starter-vector-store-pgvector` provides `PgVectorStore`, auto-configured from `application.yaml` (no manual config class)
|
||||||
|
- Embedding dimensions must stay consistent with the configured LiteLLM embedding model — changing models requires re-syncing all indexed items
|
||||||
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
|
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
|
||||||
|
|
||||||
### Testing Strategy
|
### Testing Strategy
|
||||||
|
|
||||||
Integration tests spin up a **mirrored PostgreSQL container** on port 5434 (same schema as production). WireMock mocks external HTTP APIs (Homebox, WikiJS). Do not mock the database in integration tests — the mirrored container strategy exists specifically to catch schema/migration divergence.
|
Integration tests spin up a **mirrored PostgreSQL container** on port 5434 (same schema as production). WireMock mocks external HTTP APIs (Homebox, WikiJS). Do not mock the database in integration tests — the mirrored container strategy exists specifically to catch schema/migration divergence.
|
||||||
|
|
||||||
|
## Chat Operations
|
||||||
|
|
||||||
|
Don't make code suggestions and changes unless explicitly asked. Treat every prompt as a discussion of latest best practice coding approaches.
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ This project serves as a transparent portfolio of my take on:
|
|||||||
---
|
---
|
||||||
|
|
||||||
## What is Vaessl?
|
## 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
|
### Core Functionality
|
||||||
* **Staging:** Raw images and metadata are stored in a PostgreSQL staging table.
|
* **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 utilizes **LiteLLM** as a unified gateway to process inputs via LLM of choice.
|
* **AI-Powered Analysis:** The system utilises **LiteLLM** as a unified gateway to process inputs via LLM of choice *(pipeline in progress)*.
|
||||||
* **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").
|
* **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 |
|
| Layer | Technology |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| **Backend** | Spring Boot 4.0.x, Java 25 (LTS), Spring AI |
|
| **Backend** | Spring Boot 4.1.0, Java 25 (LTS), Spring AI |
|
||||||
| **Frontend** | React (Vite), Tailwind CSS, SCSS |
|
| **Frontend** | React 19, Vite 8, SCSS |
|
||||||
| **Database** | PostgreSQL + `pgvector` |
|
| **Database** | PostgreSQL + `pgvector` |
|
||||||
| **AI Gateway** | LiteLLM (Unified API proxy) |
|
| **AI Gateway** | LiteLLM (Unified API proxy) |
|
||||||
| **DevOps** | Docker, Portainer, Hoppscotch (API testing), Gitea Actions, Jenkins |
|
| **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
|
### 2. Abstraction & Scalability
|
||||||
A core requirement was to prevent vendor or application lock-in:
|
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.
|
* **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
|
### 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.
|
* **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.
|
||||||
* **Frontend TDD:** A Vitest-based stack provides a fast feedback loop for the UI, ensuring component reliability before integration with the Spring Boot backend.
|
* **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] Custom Code-Server image build and deployment.
|
||||||
* [x] Spring Boot skeleton with Java 25 and Gradle Kotlin DSL.
|
* [x] Spring Boot skeleton with Java 25 and Gradle Kotlin DSL.
|
||||||
|
|
||||||
### Phase 2: The Processing Pipeline (Current)
|
### Phase 2: Connection & Basic Search (Current)
|
||||||
* [ ] Implementation of Image/Semantic Search $\rightarrow$ AI $\rightarrow$ Staging Table workflow.
|
* [x] Provider-pattern abstraction for connections and search.
|
||||||
* [ ] Development of the data refinement UI in React.
|
* [x] Homebox authentication — login, session-tracked token, expiry checking.
|
||||||
* [ ] Initial API connector for Homebox.
|
* [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
|
### Phase 3: Semantic Search & Expansion
|
||||||
* [ ] Integration of Spring AI vector embeddings.
|
* [ ] Spring AI vector embeddings with pgvector for intent-based search.
|
||||||
* [ ] Implementation of additional API targets (WikiJS).
|
* [ ] Additional API targets (WikiJS).
|
||||||
* [ ] Launch of a public-facing demo.
|
* [ ] Public-facing demo.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup & Deployment
|
## Setup & Deployment
|
||||||
The project is orchestrated via Docker Compose for high portability.
|
|
||||||
|
|
||||||
1. Clone the Repository.
|
1. Clone the repository.
|
||||||
2. Environment Setup: Configure `.env.local` with your database credentials and AI API keys.
|
2. Copy `.env.local` into `backend/` with:
|
||||||
|
|
||||||
```
|
```env
|
||||||
DB_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl
|
DB_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl
|
||||||
DB_TEST_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl_test
|
DB_TEST_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl_test
|
||||||
DB_USERNAME={username}
|
DB_USERNAME={username}
|
||||||
DB_PASSWORD={password}
|
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
|
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,12 @@
|
|||||||
|
val wiremockVersion = "3.12.0"
|
||||||
|
val postgresqlVersion = "42.7.11"
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
java
|
java
|
||||||
|
jacoco
|
||||||
id("org.springframework.boot") version "4.1.0"
|
id("org.springframework.boot") version "4.1.0"
|
||||||
id("io.spring.dependency-management") version "1.1.7"
|
id("io.spring.dependency-management") version "1.1.7"
|
||||||
|
id("org.sonarqube") version "7.3.0.8198"
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "com.vaessl"
|
group = "com.vaessl"
|
||||||
@@ -13,6 +18,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 {
|
configurations {
|
||||||
compileOnly {
|
compileOnly {
|
||||||
extendsFrom(configurations.annotationProcessor.get())
|
extendsFrom(configurations.annotationProcessor.get())
|
||||||
@@ -25,6 +39,7 @@ repositories {
|
|||||||
|
|
||||||
extra["springAiVersion"] = "2.0.0"
|
extra["springAiVersion"] = "2.0.0"
|
||||||
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-session-jdbc")
|
implementation("org.springframework.boot:spring-boot-starter-session-jdbc")
|
||||||
@@ -32,17 +47,28 @@ dependencies {
|
|||||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
||||||
implementation("org.springframework.ai:spring-ai-starter-model-openai")
|
implementation("org.springframework.ai:spring-ai-starter-model-openai")
|
||||||
|
implementation("org.postgresql:postgresql:$postgresqlVersion")
|
||||||
|
implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
compileOnly("org.projectlombok:lombok")
|
compileOnly("org.projectlombok:lombok")
|
||||||
|
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
|
|
||||||
runtimeOnly("org.postgresql:postgresql")
|
runtimeOnly("org.postgresql:postgresql")
|
||||||
|
|
||||||
annotationProcessor("org.projectlombok:lombok")
|
annotationProcessor("org.projectlombok:lombok")
|
||||||
|
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
|
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-security-test")
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||||
|
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")
|
||||||
|
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
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 {
|
dependencyManagement {
|
||||||
imports {
|
imports {
|
||||||
@@ -56,4 +82,19 @@ tasks.withType<JavaCompile> {
|
|||||||
|
|
||||||
tasks.withType<Test> {
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
@Configuration
|
@Configuration
|
||||||
public class CorsConfig implements WebMvcConfigurer {
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
@Value("${vaessl.frontend-local-url}")
|
@Value("${vaessl.allowed-origins}")
|
||||||
private String frontendLocalUrl;
|
private String[] allowedOrigins;
|
||||||
|
|
||||||
@Value("${vaessl.frontend-public-url}")
|
|
||||||
private String frontendPublicUrl;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**").allowedOrigins(allowedOrigins)
|
||||||
.allowedOrigins(frontendLocalUrl, frontendPublicUrl)
|
|
||||||
.allowedMethods("GET", "POST", "DELETE", "OPTIONS")
|
.allowedMethods("GET", "POST", "DELETE", "OPTIONS")
|
||||||
.allowedHeaders("Content-Type", "Accept")
|
.allowedHeaders("Content-Type", "Accept").allowCredentials(true);
|
||||||
.allowCredentials(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -14,33 +14,28 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import com.vaessl.app.dto.AuthResponse;
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.dto.ConnectionStatusResponse;
|
|
||||||
import com.vaessl.app.dto.LoginResult;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
import com.vaessl.app.shared.SessionKeys;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ConnectionController {
|
public class ConnectionController {
|
||||||
|
|
||||||
private static final String SUFFIX = "_CONNECTION_ID";
|
|
||||||
|
|
||||||
private final ConnectionService connectionService;
|
private final ConnectionService connectionService;
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<AuthResponse> login(
|
public ResponseEntity<AuthResponse> login(@Valid @RequestBody ConnectionRequest request,
|
||||||
@Valid @RequestBody ConnectionRequest request,
|
|
||||||
HttpServletRequest httpReq) {
|
HttpServletRequest httpReq) {
|
||||||
|
|
||||||
LoginResult result = connectionService.login(request);
|
LoginResult result = connectionService.login(request);
|
||||||
|
|
||||||
HttpSession session = httpReq.getSession(true);
|
HttpSession session = httpReq.getSession(true);
|
||||||
session.setAttribute(request.serviceType() + SUFFIX, result.connectionId());
|
session.setAttribute(SessionKeys.connectionId(request.serviceType()), result.connectionId());
|
||||||
|
|
||||||
if (result.expiresAt() != null) {
|
if (result.expiresAt() != null) {
|
||||||
long secs = Instant.now().until(result.expiresAt(), ChronoUnit.SECONDS);
|
long secs = Instant.now().until(result.expiresAt(), ChronoUnit.SECONDS);
|
||||||
@@ -59,27 +54,33 @@ public class ConnectionController {
|
|||||||
|
|
||||||
List<ConnectionStatusResponse> statuses = new ArrayList<>();
|
List<ConnectionStatusResponse> statuses = new ArrayList<>();
|
||||||
Collections.list(session.getAttributeNames()).stream()
|
Collections.list(session.getAttributeNames()).stream()
|
||||||
.filter(k -> k.endsWith(SUFFIX))
|
.filter(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX))
|
||||||
.forEach(k -> {
|
.forEach(k -> {
|
||||||
String serviceType = k.replace(SUFFIX, "");
|
String name = k.replace(SessionKeys.CONNECTION_ID_SUFFIX, "");
|
||||||
|
try {
|
||||||
|
ServiceType serviceType = ServiceType.valueOf(name);
|
||||||
Long id = (Long) session.getAttribute(k);
|
Long id = (Long) session.getAttribute(k);
|
||||||
ConnectionStatusResponse status = connectionService.getConnectionStatus(serviceType, id);
|
ConnectionStatusResponse status =
|
||||||
if (status != null) statuses.add(status);
|
connectionService.getConnectionStatus(serviceType, id);
|
||||||
|
if (status != null)
|
||||||
|
statuses.add(status);
|
||||||
|
} catch (IllegalArgumentException _) {
|
||||||
|
// stale session key from an old or removed service type — skip it
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return ResponseEntity.ok(statuses);
|
return ResponseEntity.ok(statuses);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/connections/{serviceType}")
|
@DeleteMapping("/connections/{serviceType}")
|
||||||
public ResponseEntity<Void> logout(
|
public ResponseEntity<Void> logout(@PathVariable("serviceType") ServiceType serviceType,
|
||||||
@PathVariable("serviceType") String serviceType,
|
|
||||||
HttpServletRequest httpReq) {
|
HttpServletRequest httpReq) {
|
||||||
|
|
||||||
HttpSession session = httpReq.getSession(false);
|
HttpSession session = httpReq.getSession(false);
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
session.removeAttribute(serviceType + SUFFIX);
|
session.removeAttribute(SessionKeys.connectionId(serviceType));
|
||||||
boolean hasMore = Collections.list(session.getAttributeNames()).stream()
|
boolean hasMore = Collections.list(session.getAttributeNames()).stream()
|
||||||
.anyMatch(k -> k.endsWith(SUFFIX));
|
.anyMatch(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX));
|
||||||
if (!hasMore) {
|
if (!hasMore) {
|
||||||
session.invalidate();
|
session.invalidate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import lombok.NoArgsConstructor;
|
|||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
|
@Table(name = "connections",
|
||||||
|
uniqueConstraints = {@UniqueConstraint(columnNames = {"appUrl", "username"})})
|
||||||
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||||
@DiscriminatorColumn(name = "service_type")
|
@DiscriminatorColumn(name = "service_type")
|
||||||
@Getter
|
@Getter
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifies the connection a request targets. Implemented by request records
|
||||||
|
* (e.g. {@code SearchRequest}, {@code SyncRequest}) so a single {@code HomeboxItemClient} can
|
||||||
|
* resolve the underlying connection regardless of which feature is calling it.
|
||||||
|
*/
|
||||||
|
public interface ConnectionIdentifiable {
|
||||||
|
|
||||||
|
String appUrl();
|
||||||
|
|
||||||
|
String username();
|
||||||
|
|
||||||
|
ServiceType serviceType();
|
||||||
|
}
|
||||||
@@ -2,15 +2,12 @@ package com.vaessl.app.connection;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
import com.vaessl.app.shared.ServiceProvider;
|
||||||
import com.vaessl.app.dto.ConnectionResponse;
|
|
||||||
|
|
||||||
public interface ConnectionProvider {
|
public interface ConnectionProvider extends ServiceProvider {
|
||||||
|
|
||||||
void checkCredentials(ConnectionRequest request);
|
void checkCredentials(ConnectionRequest request);
|
||||||
|
|
||||||
String getServiceType();
|
|
||||||
|
|
||||||
ConnectionResponse authenticate(ConnectionRequest request);
|
ConnectionResponse authenticate(ConnectionRequest request);
|
||||||
|
|
||||||
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
|
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-3
@@ -1,12 +1,13 @@
|
|||||||
package com.vaessl.app.dto;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
|
public record ConnectionResponse(String token, Instant expiresAt,
|
||||||
|
Map<String, Object> extraResponseData) {
|
||||||
|
|
||||||
public String getExtraVar(String key) {
|
public String getExtraVar(String key) {
|
||||||
if(extraResponseData == null) {
|
if (extraResponseData == null) {
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
Object value = extraResponseData.get(key);
|
Object value = extraResponseData.get(key);
|
||||||
@@ -1,28 +1,28 @@
|
|||||||
package com.vaessl.app.connection;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.dto.ConnectionResponse;
|
|
||||||
import com.vaessl.app.dto.ConnectionStatusResponse;
|
|
||||||
import com.vaessl.app.dto.LoginResult;
|
|
||||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ConnectionService {
|
public class ConnectionService {
|
||||||
|
|
||||||
private final Map<String, ConnectionProvider> providerRegistry;
|
private final Map<ServiceType, ConnectionProvider> providerRegistry;
|
||||||
|
|
||||||
private final ConnectionRepository cRepository;
|
private final ConnectionRepository cRepository;
|
||||||
|
|
||||||
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
|
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
|
||||||
this.providerRegistry = providers.stream()
|
Map<ServiceType, ConnectionProvider> registry = new EnumMap<>(ServiceType.class);
|
||||||
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
|
for (ConnectionProvider provider : providers) {
|
||||||
|
registry.put(provider.getServiceType(), provider);
|
||||||
|
}
|
||||||
|
this.providerRegistry = registry;
|
||||||
this.cRepository = cRepository;
|
this.cRepository = cRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,15 +52,17 @@ public class ConnectionService {
|
|||||||
return new LoginResult(saved.getId(), response.expiresAt());
|
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);
|
ConnectionEntity entity = cRepository.findById(connectionId).orElse(null);
|
||||||
if (entity == null) return null;
|
if (entity == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
ConnectionProvider provider = providerRegistry.get(serviceType);
|
ConnectionProvider provider = providerRegistry.get(serviceType);
|
||||||
Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null;
|
Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null;
|
||||||
boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now());
|
boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now());
|
||||||
|
|
||||||
return new ConnectionStatusResponse(serviceType, entity.getAppUrl(),
|
return new ConnectionStatusResponse(serviceType.name(), entity.getAppUrl(),
|
||||||
entity.getUsername(), expiresAt, connected);
|
entity.getUsername(), expiresAt, connected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record ConnectionStatusResponse(String serviceType, String appUrl, String username,
|
||||||
|
Instant expiresAt, boolean connected) {
|
||||||
|
}
|
||||||
@@ -1,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-6
@@ -2,9 +2,6 @@ package com.vaessl.app.connection;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.dto.ConnectionResponse;
|
|
||||||
|
|
||||||
import jakarta.persistence.DiscriminatorValue;
|
import jakarta.persistence.DiscriminatorValue;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -14,15 +11,15 @@ import lombok.Setter;
|
|||||||
@DiscriminatorValue("HOMEBOX")
|
@DiscriminatorValue("HOMEBOX")
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
public class HomeboxEntity extends ConnectionEntity {
|
public class HomeboxConnectionEntity extends ConnectionEntity {
|
||||||
|
|
||||||
private String token;
|
private String token;
|
||||||
private String attachmentToken;
|
private String attachmentToken;
|
||||||
private Instant expiresAt;
|
private Instant expiresAt;
|
||||||
|
|
||||||
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
|
public static HomeboxConnectionEntity from(ConnectionRequest request, ConnectionResponse response) {
|
||||||
|
|
||||||
HomeboxEntity he = new HomeboxEntity();
|
HomeboxConnectionEntity he = new HomeboxConnectionEntity();
|
||||||
|
|
||||||
he.setAppUrl(request.appUrl());
|
he.setAppUrl(request.appUrl());
|
||||||
he.setUsername(request.username());
|
he.setUsername(request.username());
|
||||||
+24
-27
@@ -9,24 +9,20 @@ import java.util.Map;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.dto.ConnectionResponse;
|
|
||||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||||
|
import com.vaessl.app.exception.RemoteApiException;
|
||||||
import static com.vaessl.app.connection.Endpoint.*;
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import static com.vaessl.app.shared.Endpoint.*;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class HomeBoxConnectionProvider implements ConnectionProvider {
|
@RequiredArgsConstructor
|
||||||
|
public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||||
|
|
||||||
private final RestClient.Builder restClientBuilder;
|
private final RestClient.Builder restClientBuilder;
|
||||||
|
|
||||||
private final ConnectionRepository cRepository;
|
private final ConnectionRepository cRepository;
|
||||||
|
|
||||||
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
|
|
||||||
this.restClientBuilder = restClientBuilder;
|
|
||||||
this.cRepository = cRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void checkCredentials(ConnectionRequest request) {
|
public void checkCredentials(ConnectionRequest request) {
|
||||||
if (request.username() == null || request.password() == null) {
|
if (request.username() == null || request.password() == null) {
|
||||||
@@ -44,33 +40,32 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getServiceType() {
|
public ServiceType getServiceType() {
|
||||||
return "HOMEBOX";
|
return ServiceType.HOMEBOX;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ConnectionResponse authenticate(ConnectionRequest request) {
|
public ConnectionResponse authenticate(ConnectionRequest request) {
|
||||||
Map<String, Object> homeboxPayload = Map.of("username", request.username(),
|
Map<String, Object> homeboxPayload = Map.of("username", request.username(), "password",
|
||||||
"password", request.password(), "stayLoggedIn",
|
request.password(), "stayLoggedIn", request.stayLoggedIn());
|
||||||
request.stayLoggedIn());
|
|
||||||
|
|
||||||
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
|
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().post()
|
||||||
.build()
|
.uri(HOMEBOX_LOGIN.getValue()).body(homeboxPayload).retrieve()
|
||||||
.post()
|
|
||||||
.uri(HOMEBOX_LOGIN.getValue())
|
|
||||||
.body(homeboxPayload)
|
|
||||||
.retrieve()
|
|
||||||
.body(HomeboxLoginResponse.class);
|
.body(HomeboxLoginResponse.class);
|
||||||
|
|
||||||
if (hbResponse == null) {
|
if (hbResponse == null) {
|
||||||
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
|
throw new RemoteApiException(request.appUrl(), HOMEBOX_LOGIN.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> attachmentToken = new HashMap<>();
|
Map<String, Object> attachmentToken = new HashMap<>();
|
||||||
|
|
||||||
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
|
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
|
||||||
|
|
||||||
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
|
String hbRawToken = hbResponse.token();
|
||||||
|
|
||||||
|
String token = hbRawToken.startsWith("Bearer ") ? hbRawToken.substring(7) : hbRawToken;
|
||||||
|
|
||||||
|
return new ConnectionResponse(token, hbResponse.expiresAt(), attachmentToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -80,14 +75,15 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
|
public ConnectionEntity connectionToEntity(ConnectionRequest request,
|
||||||
return HomeboxEntity.from(request, response);
|
ConnectionResponse response) {
|
||||||
|
return HomeboxConnectionEntity.from(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
|
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
|
||||||
|
|
||||||
if (existing instanceof HomeboxEntity hbE) {
|
if (existing instanceof HomeboxConnectionEntity hbE) {
|
||||||
|
|
||||||
hbE.setToken(response.token());
|
hbE.setToken(response.token());
|
||||||
hbE.setExpiresAt(response.expiresAt());
|
hbE.setExpiresAt(response.expiresAt());
|
||||||
@@ -99,10 +95,11 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Instant getTokenExpiry(ConnectionEntity entity) {
|
public Instant getTokenExpiry(ConnectionEntity entity) {
|
||||||
return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null;
|
return (entity instanceof HomeboxConnectionEntity he) ? he.getExpiresAt() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
|
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.vaessl.app.dto;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record LoginResult(Long connectionId, Instant expiresAt) {}
|
public record LoginResult(Long connectionId, Instant expiresAt) {
|
||||||
|
}
|
||||||
@@ -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,5 +0,0 @@
|
|||||||
package com.vaessl.app.dto;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
public record AuthResponse(String serviceType, Instant expiresAt) {}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.vaessl.app.dto;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
public record ConnectionStatusResponse(
|
|
||||||
String serviceType,
|
|
||||||
String appUrl,
|
|
||||||
String username,
|
|
||||||
Instant expiresAt,
|
|
||||||
boolean connected) {}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.vaessl.app.exception;
|
||||||
|
|
||||||
|
public class ConnectionNotFoundException extends RuntimeException {
|
||||||
|
}
|
||||||
@@ -5,11 +5,20 @@ import org.springframework.http.HttpStatus;
|
|||||||
import com.fasterxml.jackson.annotation.JsonValue;
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
|
||||||
public enum ErrorMessage {
|
public enum ErrorMessage {
|
||||||
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(
|
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST,
|
||||||
HttpStatus.UNAUTHORIZED, "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
|
"Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(HttpStatus.UNAUTHORIZED,
|
||||||
HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."), SERVER_ERROR_GENERAL(
|
"Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
|
||||||
"The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.NOT_FOUND,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
"No such service type.");
|
"The target URL is unreachable."), SERVER_ERROR_GENERAL(
|
||||||
|
"The external app returned a server error: "), WRONG_SERVICE_TYPE(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"No such service type."), CONNECTION_NOT_FOUND(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"No active connection found for this service."), REMOTE_API_EMPTY_RESPONSE(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"Remote API returned empty response for "), REMOTE_API_CLIENT_ERROR(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"Remote API returned an unexpected error: ");
|
||||||
|
|
||||||
private final HttpStatus status;
|
private final HttpStatus status;
|
||||||
private final String message;
|
private final String message;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package com.vaessl.app.exception;
|
package com.vaessl.app.exception;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.ProblemDetail;
|
import org.springframework.http.ProblemDetail;
|
||||||
import org.springframework.validation.FieldError;
|
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
@@ -16,11 +17,13 @@ import java.util.stream.Collectors;
|
|||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
public class GlobalExceptionHandler {
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
|
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
|
||||||
|
|
||||||
String defaultMessages = e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage)
|
String defaultMessages = e.getBindingResult().getFieldErrors().stream()
|
||||||
.collect(Collectors.joining(", "));
|
.map(field -> field.getDefaultMessage()).collect(Collectors.joining(", "));
|
||||||
|
|
||||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
|
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
|
||||||
@@ -33,6 +36,15 @@ public class GlobalExceptionHandler {
|
|||||||
UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
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)
|
@ExceptionHandler(ResourceAccessException.class)
|
||||||
public ProblemDetail handleNoConnection(ResourceAccessException e) {
|
public ProblemDetail handleNoConnection(ResourceAccessException e) {
|
||||||
|
|
||||||
@@ -42,15 +54,15 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(HttpServerErrorException.class)
|
@ExceptionHandler(HttpServerErrorException.class)
|
||||||
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
|
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
|
||||||
|
log.error("Remote API server error {}: {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
return ProblemDetail
|
return ProblemDetail.forStatusAndDetail(e.getStatusCode(),
|
||||||
.forStatusAndDetail(e.getStatusCode(),
|
|
||||||
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
|
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(WrongServiceTypeException.class)
|
@ExceptionHandler(WrongServiceTypeException.class)
|
||||||
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
|
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
|
||||||
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage());
|
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(),
|
||||||
|
WRONG_SERVICE_TYPE.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(EmptyCredentialsException.class)
|
@ExceptionHandler(EmptyCredentialsException.class)
|
||||||
@@ -58,4 +70,17 @@ public class GlobalExceptionHandler {
|
|||||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
|
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ConnectionNotFoundException.class)
|
||||||
|
public ProblemDetail handleConnectionNotFound(ConnectionNotFoundException e) {
|
||||||
|
return ProblemDetail.forStatusAndDetail(CONNECTION_NOT_FOUND.getStatus(),
|
||||||
|
CONNECTION_NOT_FOUND.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(RemoteApiException.class)
|
||||||
|
public ProblemDetail handleRemoteApiException(RemoteApiException e) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.vaessl.app.homebox;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
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.ConnectionIdentifiable;
|
||||||
|
import com.vaessl.app.connection.ConnectionRepository;
|
||||||
|
import com.vaessl.app.connection.HomeboxConnectionEntity;
|
||||||
|
import com.vaessl.app.exception.ConnectionNotFoundException;
|
||||||
|
import com.vaessl.app.exception.RemoteApiException;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches items from the Homebox entities API. Shared by {@code HomeboxSearchProvider} and
|
||||||
|
* {@code HomeboxSyncProvider} so both keyword search and vector-store sync page through the same
|
||||||
|
* remote call and item mapping.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class HomeboxItemClient {
|
||||||
|
|
||||||
|
private final RestClient.Builder restClientBuilder;
|
||||||
|
|
||||||
|
private final ConnectionRepository cRepository;
|
||||||
|
|
||||||
|
|
||||||
|
public HomeboxItemClient(RestClient.Builder restClienBuilder,
|
||||||
|
ConnectionRepository cRepository) {
|
||||||
|
this.restClientBuilder = restClienBuilder;
|
||||||
|
this.cRepository = cRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches one page of items from Homebox for the given connection.
|
||||||
|
*
|
||||||
|
* @param connection identifies which stored connection (app URL, username) to query
|
||||||
|
* @param query optional keyword filter; {@code null} returns all items for the page
|
||||||
|
* @param pageable page number and size to request
|
||||||
|
* @return the mapped page of items along with the resolved connection ID
|
||||||
|
* @throws com.vaessl.app.exception.ConnectionNotFoundException if no matching connection is stored
|
||||||
|
* @throws com.vaessl.app.exception.RemoteApiException if Homebox returns an empty response body
|
||||||
|
*/
|
||||||
|
public HomeboxItemPage hbResponse(ConnectionIdentifiable connection, String query,
|
||||||
|
Pageable pageable) {
|
||||||
|
ConnectionEntity entity =
|
||||||
|
cRepository.findByAppUrlAndUsername(connection.appUrl(), connection.username());
|
||||||
|
|
||||||
|
if (!(entity instanceof HomeboxConnectionEntity hbEntity)) {
|
||||||
|
throw new ConnectionNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeboxItemsResponse response = restClientBuilder.baseUrl(connection.appUrl()).build().get()
|
||||||
|
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue()).queryParam("q", query)
|
||||||
|
.queryParam("page", pageable.getPageNumber() + 1)
|
||||||
|
.queryParam("pageSize", pageable.getPageSize()).build())
|
||||||
|
.headers(h -> h.setBearerAuth(hbEntity.getToken())).retrieve()
|
||||||
|
.body(HomeboxItemsResponse.class);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
throw new RemoteApiException(connection.appUrl(), HOMEBOX_QUERY_ALL_ITEMS.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ServiceItem> items = response.items().stream().map(i -> {
|
||||||
|
String id = i.id();
|
||||||
|
String title = i.name();
|
||||||
|
String description = i.description();
|
||||||
|
|
||||||
|
HomeboxParent parent = i.parent();
|
||||||
|
Map<String, Object> extraData = new HashMap<>();
|
||||||
|
if (parent.name() != null && !parent.name().isBlank()) {
|
||||||
|
extraData.put("locationName", parent.name());
|
||||||
|
}
|
||||||
|
if (parent.description() != null && !parent.description().isBlank()) {
|
||||||
|
extraData.put("locationDescription", parent.description());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServiceItem(id, title, description, extraData);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return new HomeboxItemPage(new PageImpl<>(items, pageable, response.total()),
|
||||||
|
hbEntity.getId());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public record HomeboxItemPage(Page<ServiceItem> page, Long connectionId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record HomeboxItemsResponse(int page, int pageSize, int total,
|
||||||
|
List<HomeboxItem> items) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record HomeboxItem(String id, String name, String description, HomeboxParent parent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record HomeboxParent(String name, String description) {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class HomeboxAiSearchProvider implements SearchProvider {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ServiceType getServiceType() {
|
||||||
|
return ServiceType.HOMEBOX;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable) {
|
||||||
|
// TODO Auto-generated method stub
|
||||||
|
throw new UnsupportedOperationException("Unimplemented method 'getSearchResults'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.vaessl.app.homebox.HomeboxItemClient;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class HomeboxSearchProvider implements SearchProvider {
|
||||||
|
|
||||||
|
private final HomeboxItemClient client;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ServiceType getServiceType() {
|
||||||
|
return ServiceType.HOMEBOX;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable) {
|
||||||
|
return client.hbResponse(request, request.query(), pageable).page();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
|
||||||
|
public record PagedSearchResponse<T>(List<T> content, int page, int pageSize, long totalElements,
|
||||||
|
boolean first, boolean last, String sort, String summary) {
|
||||||
|
|
||||||
|
public static <T> PagedSearchResponse<T> from(Page<T> pageResult) {
|
||||||
|
return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(),
|
||||||
|
pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(),
|
||||||
|
pageResult.isLast(), pageResult.getSort().toString(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.ServiceItem;
|
||||||
|
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<PagedSearchResponse<ServiceItem>> 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<ServiceItem> result = searchService.search(request, pageable);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(PagedSearchResponse.from(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
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
|
||||||
|
* @param pageable the Pageable interface
|
||||||
|
* @return a list of Page<SearchResponse> items matching the query
|
||||||
|
*/
|
||||||
|
Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import com.vaessl.app.connection.ConnectionIdentifiable;
|
||||||
|
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, boolean aiSearch) implements ConnectionIdentifiable {
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SearchService {
|
||||||
|
|
||||||
|
private final Map<ServiceType, SearchProvider> providerRegistry;
|
||||||
|
|
||||||
|
public SearchService(List<SearchProvider> providers) {
|
||||||
|
Map<ServiceType, SearchProvider> registry = new EnumMap<>(ServiceType.class);
|
||||||
|
for (SearchProvider provider : providers) {
|
||||||
|
registry.put(provider.getServiceType(), provider);
|
||||||
|
}
|
||||||
|
this.providerRegistry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ServiceItem> search(SearchRequest request, Pageable pageable) {
|
||||||
|
|
||||||
|
SearchProvider provider = providerRegistry.get(request.serviceType());
|
||||||
|
|
||||||
|
if (provider == null) {
|
||||||
|
throw new WrongServiceTypeException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.getSearchResults(request, pageable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.vaessl.app.shared;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record ServiceItem(String id, @NotNull String title, String description,
|
||||||
|
Map<String, Object> extraData) {
|
||||||
|
|
||||||
|
public String getExtra(String key) {
|
||||||
|
if (extraData == null) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
Object value = extraData.get(key);
|
||||||
|
|
||||||
|
return value != null ? String.valueOf(value) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.vaessl.app.sync;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import com.vaessl.app.homebox.HomeboxItemClient;
|
||||||
|
import com.vaessl.app.homebox.HomeboxItemClient.HomeboxItemPage;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
import com.vaessl.app.vector.EmbeddingService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages through the full Homebox catalog and re-indexes it into the vector store.
|
||||||
|
* Item IDs are collected across all pages before {@link EmbeddingService#deleteStaleVectorEntries}
|
||||||
|
* is called once at the end, rather than after each page: deleting per page would treat every
|
||||||
|
* item outside the current page as stale, wiping out entries from pages already synced.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class HomeboxSyncProvider implements SyncProvider {
|
||||||
|
|
||||||
|
private final EmbeddingService embeddingService;
|
||||||
|
|
||||||
|
private final HomeboxItemClient client;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ServiceType getServiceType() {
|
||||||
|
return ServiceType.HOMEBOX;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void syncVectorStore(SyncRequest request) {
|
||||||
|
|
||||||
|
int batchSize = 100;
|
||||||
|
int page = 0;
|
||||||
|
Page<ServiceItem> current;
|
||||||
|
Long connectionId = null;
|
||||||
|
List<String> currentItemIds = new ArrayList<>();
|
||||||
|
do {
|
||||||
|
Pageable pageable = PageRequest.of(page, batchSize);
|
||||||
|
HomeboxItemPage result = client.hbResponse(request, null, pageable);
|
||||||
|
current = result.page();
|
||||||
|
if (connectionId == null) {
|
||||||
|
connectionId = result.connectionId();
|
||||||
|
}
|
||||||
|
embeddingService.vectorizeData(current.getContent(), request.serviceType(),
|
||||||
|
result.connectionId(), currentItemIds);
|
||||||
|
page++;
|
||||||
|
} while (page < current.getTotalPages());
|
||||||
|
|
||||||
|
embeddingService.deleteStaleVectorEntries(connectionId, currentItemIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.vaessl.app.sync;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class SyncController {
|
||||||
|
|
||||||
|
private final SyncService syncService;
|
||||||
|
|
||||||
|
public SyncController(SyncService syncService) {
|
||||||
|
this.syncService = syncService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a vector-store sync for the requested service. Returns {@code 401 Unauthorized}
|
||||||
|
* if there is no active session.
|
||||||
|
*/
|
||||||
|
@PostMapping("/sync")
|
||||||
|
public ResponseEntity<Object> syncVectorStore(@Valid @RequestBody SyncRequest request,
|
||||||
|
HttpServletRequest httpReq) {
|
||||||
|
|
||||||
|
HttpSession session = httpReq.getSession(false);
|
||||||
|
|
||||||
|
if (session == null
|
||||||
|
|| session.getAttribute(SessionKeys.connectionId(request.serviceType())) == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
}
|
||||||
|
syncService.syncServiceVectorStore(request);
|
||||||
|
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.vaessl.app.sync;
|
||||||
|
|
||||||
|
import com.vaessl.app.shared.ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implemented by any service that supports indexing its items into the vector store.
|
||||||
|
*/
|
||||||
|
public interface SyncProvider extends ServiceProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all items from the remote service and indexes them into the vector store,
|
||||||
|
* removing any previously indexed items that no longer exist upstream.
|
||||||
|
*
|
||||||
|
* @param request the sync request containing the app URL and user credentials
|
||||||
|
*/
|
||||||
|
public void syncVectorStore(SyncRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.vaessl.app.sync;
|
||||||
|
|
||||||
|
import com.vaessl.app.connection.ConnectionIdentifiable;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record SyncRequest(@NotBlank String appUrl, @NotBlank String username,
|
||||||
|
@NotNull ServiceType serviceType) implements ConnectionIdentifiable {
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.vaessl.app.sync;
|
||||||
|
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SyncService {
|
||||||
|
|
||||||
|
private final Map<ServiceType, SyncProvider> providerRegistry;
|
||||||
|
|
||||||
|
public SyncService(List<SyncProvider> providers) {
|
||||||
|
Map<ServiceType, SyncProvider> registry = new EnumMap<>(ServiceType.class);
|
||||||
|
for (SyncProvider provider : providers) {
|
||||||
|
registry.put(provider.getServiceType(), provider);
|
||||||
|
}
|
||||||
|
this.providerRegistry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches the vector-store sync request to the provider registered for
|
||||||
|
* {@link SyncRequest#serviceType()}.
|
||||||
|
*
|
||||||
|
* @param request the sync request
|
||||||
|
* @throws WrongServiceTypeException if no provider is registered for the given service type
|
||||||
|
*/
|
||||||
|
public void syncServiceVectorStore(SyncRequest request) {
|
||||||
|
|
||||||
|
SyncProvider provider = providerRegistry.get(request.serviceType());
|
||||||
|
|
||||||
|
if (provider == null) {
|
||||||
|
throw new WrongServiceTypeException();
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.syncVectorStore(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.vaessl.app.vector;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.ai.document.Document;
|
||||||
|
import org.springframework.ai.vectorstore.VectorStore;
|
||||||
|
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embeds {@link ServiceItem}s into the shared vector store and prunes entries that no longer
|
||||||
|
* exist upstream. Kept stateless (no instance fields besides {@code vectorStore}) since this is
|
||||||
|
* a singleton bean shared across concurrent sync runs; callers own the per-sync ID accumulator.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class EmbeddingService {
|
||||||
|
|
||||||
|
private static final float THRESHOLD = 0.7f;
|
||||||
|
private static final int LIMIT = 2;
|
||||||
|
private final VectorStore vectorStore;
|
||||||
|
|
||||||
|
public EmbeddingService(VectorStore vectorStore) {
|
||||||
|
this.vectorStore = vectorStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embeds and upserts {@code items} into the vector store, appending each item's ID to
|
||||||
|
* {@code currentItemIds} so the caller can later prune stale entries via
|
||||||
|
* {@link #deleteStaleVectorEntries}. Safe to call repeatedly (e.g. once per page) against the
|
||||||
|
* same accumulator across a single sync run.
|
||||||
|
*
|
||||||
|
* @param items the items to embed for this batch
|
||||||
|
* @param serviceType the originating service, stored in each document's metadata
|
||||||
|
* @param connectionId the connection these items belong to
|
||||||
|
* @param currentItemIds accumulator collecting every item ID seen so far in this sync run
|
||||||
|
*/
|
||||||
|
public void vectorizeData(List<ServiceItem> items, ServiceType serviceType, Long connectionId,
|
||||||
|
List<String> currentItemIds) {
|
||||||
|
List<Document> documents = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ServiceItem item : items) {
|
||||||
|
documents.add(toDocument(item, serviceType, connectionId));
|
||||||
|
currentItemIds.add(item.id());
|
||||||
|
}
|
||||||
|
vectorStore.add(documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document toDocument(ServiceItem item, ServiceType serviceType, Long connectionId) {
|
||||||
|
String extraData = buildExtraData(item.extraData());
|
||||||
|
Map<String, Object> metadata = buildMetadata(item, serviceType, connectionId);
|
||||||
|
String content = buildContent(item, extraData);
|
||||||
|
return new Document(connectionId + ":" + item.id(), content, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildExtraData(Map<String, Object> extraData) {
|
||||||
|
if (extraData == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder data = new StringBuilder();
|
||||||
|
for (Map.Entry<String, Object> entry : extraData.entrySet()) {
|
||||||
|
if (entry.getValue() == null || entry.getValue().toString().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!data.isEmpty()) {
|
||||||
|
data.append(" \n");
|
||||||
|
}
|
||||||
|
data.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||||
|
}
|
||||||
|
return data.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildMetadata(ServiceItem item, ServiceType serviceType,
|
||||||
|
Long connectionId) {
|
||||||
|
Map<String, Object> metadata = new HashMap<>();
|
||||||
|
metadata.put("connectionId", connectionId);
|
||||||
|
metadata.put("serviceType", serviceType.name());
|
||||||
|
metadata.put("itemId", item.id());
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildContent(ServiceItem item, String extraData) {
|
||||||
|
StringBuilder content = new StringBuilder("Title: ").append(item.title());
|
||||||
|
if (item.description() != null && !item.description().isEmpty()) {
|
||||||
|
content.append("\nDescription: ").append(item.description());
|
||||||
|
}
|
||||||
|
if (!extraData.isEmpty()) {
|
||||||
|
content.append("\nExtradata: ").append(extraData);
|
||||||
|
}
|
||||||
|
return content.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes every vector for {@code connectionId} whose item ID is not in
|
||||||
|
* {@code currentItemIds}, then clears the accumulator. Must be called once, after every page
|
||||||
|
* for this sync run has gone through {@link #vectorizeData}, not per page — otherwise items
|
||||||
|
* from pages other than the most recent one would look stale and get deleted too.
|
||||||
|
*
|
||||||
|
* @param connectionId the connection to prune stale vectors for
|
||||||
|
* @param currentItemIds every item ID seen across the full sync run; cleared after this call
|
||||||
|
*/
|
||||||
|
public void deleteStaleVectorEntries(Long connectionId, List<String> currentItemIds) {
|
||||||
|
FilterExpressionBuilder b = new FilterExpressionBuilder();
|
||||||
|
vectorStore.delete(b.and(b.eq("connectionId", connectionId),
|
||||||
|
b.nin("itemId", new ArrayList<>(currentItemIds))).build());
|
||||||
|
|
||||||
|
currentItemIds.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,8 @@
|
|||||||
"description": "A description for 'spring.session.store-type'"
|
"description": "A description for 'spring.session.store-type'"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vaessl.frontend-local-url",
|
"name": "vaessl.allowed-origins",
|
||||||
"type": "java.lang.String",
|
"type": "java.lang.String",
|
||||||
"description": "A description for 'vaessl.frontend-local-url'"
|
"description": "Comma-separated list of allowed CORS origins"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vaessl.frontend-public-url",
|
|
||||||
"type": "java.lang.String",
|
|
||||||
"description": "A description for 'vaessl.frontend-public-url'"
|
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
@@ -13,15 +13,21 @@ spring:
|
|||||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: create-drop
|
ddl-auto: update
|
||||||
show-sql: true
|
show-sql: true
|
||||||
ai:
|
ai:
|
||||||
openai:
|
openai:
|
||||||
base-url: ${OPENAI_BASE_URL}
|
base-url: ${OPENAI_BASE_URL}
|
||||||
api-key: ${OPENAI_KEY}
|
api-key: ${OPENAI_KEY}
|
||||||
chat:
|
chat:
|
||||||
options:
|
|
||||||
model: gpt-4o-mini
|
model: gpt-4o-mini
|
||||||
|
vectorstore:
|
||||||
|
pgvector:
|
||||||
|
id-type: text
|
||||||
|
dimensions: 1536
|
||||||
|
distance-type: COSINE_DISTANCE
|
||||||
|
index-type: HNSW
|
||||||
|
initialize-schema: true
|
||||||
session:
|
session:
|
||||||
store-type: jdbc
|
store-type: jdbc
|
||||||
jdbc:
|
jdbc:
|
||||||
@@ -30,5 +36,4 @@ server:
|
|||||||
servlet:
|
servlet:
|
||||||
context-path: /api
|
context-path: /api
|
||||||
vaessl:
|
vaessl:
|
||||||
frontend-local-url: ${FRONTEND_LOCAL_URL}
|
allowed-origins: ${ALLOWED_ORIGINS}
|
||||||
frontend-public-url: ${FRONTEND_PUBLIC_URL}
|
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ class ApplicationTests {
|
|||||||
private DataSource dataSource;
|
private DataSource dataSource;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
void contextLoads() {}
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void connectionToTestDbWorks() throws SQLException {
|
void connectionToTestDbWorks() throws SQLException {
|
||||||
|
|||||||
@@ -0,0 +1,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";
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.vaessl.app.connection;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
import static com.vaessl.app.connection.Endpoint.*;
|
import static com.vaessl.app.Mockdata.*;
|
||||||
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.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
@@ -27,11 +29,10 @@ class ConnectionControllerTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
MockMvc mockMvc;
|
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 LOGIN_PATH = LOGIN.getValue();
|
||||||
private static final String STATUS_PATH = CONNECTION_STATUS.getValue();
|
private static final String STATUS_PATH = CONNECTION_STATUS.getValue();
|
||||||
private static final String LOGOUT_PATH = "/connections/HOMEBOX";
|
private static final String LOGOUT_PATH = "/connections/HOMEBOX";
|
||||||
|
private static final String SESSION = "SESSION";
|
||||||
|
|
||||||
private static final String VALID_HOMEBOX_RESPONSE = """
|
private static final String VALID_HOMEBOX_RESPONSE = """
|
||||||
{
|
{
|
||||||
@@ -51,62 +52,62 @@ class ConnectionControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnEmptyListWhenNoActiveSession() throws Exception {
|
void shouldReturnEmptyListWhenNoActiveSession() throws Exception {
|
||||||
mockMvc.perform(get(STATUS_PATH))
|
mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(content().json("[]"));
|
.andExpect(content().json("[]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm) throws Exception {
|
void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm)
|
||||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
throws Exception {
|
||||||
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||||
|
|
||||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
MvcResult loginResult = mockMvc
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(connectionRequestBody(wm)))
|
.content(connectionRequestBody(wm)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk()).andReturn();
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
|
||||||
|
|
||||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie))
|
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.length()").value(1))
|
.andExpect(jsonPath("$.length()").value(1))
|
||||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.name()))
|
||||||
.andExpect(jsonPath("$[0].username").value(TEST_USER))
|
.andExpect(jsonPath("$[0].username").value(MOCK_USER))
|
||||||
.andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl()))
|
.andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl()))
|
||||||
.andExpect(jsonPath("$[0].connected").value(true));
|
.andExpect(jsonPath("$[0].connected").value(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm) throws Exception {
|
void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm)
|
||||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE)));
|
throws Exception {
|
||||||
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE)));
|
||||||
|
|
||||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
MvcResult loginResult = mockMvc
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(connectionRequestBody(wm)))
|
.content(connectionRequestBody(wm)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk()).andReturn();
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
|
||||||
|
|
||||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie))
|
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.name()))
|
||||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
|
||||||
.andExpect(jsonPath("$[0].connected").value(false))
|
.andExpect(jsonPath("$[0].connected").value(false))
|
||||||
.andExpect(jsonPath("$[0].expiresAt").value("2000-01-01T00:00:00Z"));
|
.andExpect(jsonPath("$[0].expiresAt")
|
||||||
|
.value("2000-01-01T00:00:00Z"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception {
|
void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||||
|
|
||||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
MvcResult loginResult = mockMvc
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(connectionRequestBody(wm)))
|
.content(connectionRequestBody(wm)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk()).andReturn();
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
|
||||||
|
|
||||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
@@ -114,34 +115,31 @@ class ConnectionControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception {
|
void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||||
|
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||||
|
|
||||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
MvcResult loginResult = mockMvc
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(connectionRequestBody(wm)))
|
.content(connectionRequestBody(wm)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk()).andReturn();
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
|
||||||
|
|
||||||
// Verify connected before logout
|
// Verify connected before logout
|
||||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie))
|
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.length()").value(1));
|
.andExpect(jsonPath("$.length()").value(1));
|
||||||
|
|
||||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
// A new request (no session cookie, as in a fresh browser) returns no connections
|
// A new request (no session cookie, as in a fresh browser) returns no connections
|
||||||
mockMvc.perform(get(STATUS_PATH))
|
mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(content().json("[]"));
|
.andExpect(content().json("[]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception {
|
void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception {
|
||||||
mockMvc.perform(delete(LOGOUT_PATH))
|
mockMvc.perform(delete(LOGOUT_PATH)).andExpect(status().isNoContent());
|
||||||
.andExpect(status().isNoContent());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||||
@@ -152,6 +150,6 @@ class ConnectionControllerTest {
|
|||||||
"username": "%s",
|
"username": "%s",
|
||||||
"password": "%s"
|
"password": "%s"
|
||||||
}
|
}
|
||||||
""".formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS);
|
""".formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.vaessl.app.connection;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
|
import static com.vaessl.app.Mockdata.*;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
@@ -15,11 +16,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||||
|
|
||||||
import static com.vaessl.app.connection.Mockdata.*;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class ConnectionServiceTest {
|
class ConnectionServiceTest {
|
||||||
|
|
||||||
@@ -38,10 +36,11 @@ class ConnectionServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
|
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
|
||||||
ConnectionRequest request = new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
|
ConnectionRequest request =
|
||||||
|
new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
|
||||||
|
|
||||||
doThrow(new EmptyCredentialsException(List.of("username")))
|
doThrow(new EmptyCredentialsException(List.of("username"))).when(mockProvider)
|
||||||
.when(mockProvider).checkCredentials(request);
|
.checkCredentials(request);
|
||||||
|
|
||||||
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
|
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
|
||||||
|
|
||||||
|
|||||||
+6
-7
@@ -4,19 +4,18 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||||
|
import static com.vaessl.app.Mockdata.*;
|
||||||
|
import static com.vaessl.app.shared.ServiceType.HOMEBOX;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static com.vaessl.app.connection.Mockdata.*;
|
|
||||||
|
|
||||||
class HomeBoxConnectionProviderTest {
|
class HomeboxConnectionProviderTest {
|
||||||
|
|
||||||
private final HomeBoxConnectionProvider provider = new HomeBoxConnectionProvider(null, null);
|
private final HomeboxConnectionProvider provider = new HomeboxConnectionProvider(null, null);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
|
void checkCredentialsShouldThrowExceptionWhenFieldsAreMissing() {
|
||||||
ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false);
|
ConnectionRequest request = new ConnectionRequest(MOCK_URL, HOMEBOX, null, null, false);
|
||||||
|
|
||||||
EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> {
|
EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> {
|
||||||
provider.checkCredentials(request);
|
provider.checkCredentials(request);
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.vaessl.app.connection;
|
package com.vaessl.app.connection;
|
||||||
|
|
||||||
|
import static com.vaessl.app.Mockdata.*;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.resttestclient.TestRestTemplate;
|
import org.springframework.boot.resttestclient.TestRestTemplate;
|
||||||
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
|
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
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.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
import com.github.tomakehurst.wiremock.http.Fault;
|
import com.github.tomakehurst.wiremock.http.Fault;
|
||||||
@@ -14,14 +19,13 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
|||||||
|
|
||||||
import com.jayway.jsonpath.DocumentContext;
|
import com.jayway.jsonpath.DocumentContext;
|
||||||
import com.jayway.jsonpath.JsonPath;
|
import com.jayway.jsonpath.JsonPath;
|
||||||
import com.vaessl.app.dto.ConnectionRequest;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
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.exception.ErrorMessage.*;
|
||||||
import static com.vaessl.app.connection.ServiceType.*;
|
import static com.vaessl.app.shared.ServiceType.*;
|
||||||
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
@AutoConfigureTestRestTemplate
|
@AutoConfigureTestRestTemplate
|
||||||
@@ -42,9 +46,6 @@ class HomeboxIntegrationTest {
|
|||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String TEST_USER = "admin";
|
|
||||||
private static final String TEST_PASS = "pw";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns Token and status code OK when login is successful.
|
* Returns Token and status code OK when login is successful.
|
||||||
*
|
*
|
||||||
@@ -53,11 +54,10 @@ class HomeboxIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
|
void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
|
||||||
|
|
||||||
stubFor(post(HOMEBOX_LOGIN.getValue())
|
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse)));
|
||||||
.willReturn(okJson(okJsonHomeboxResponse)));
|
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||||
String.class);
|
connectionRequest(wm), String.class);
|
||||||
|
|
||||||
DocumentContext documentContext = JsonPath.parse(response.getBody());
|
DocumentContext documentContext = JsonPath.parse(response.getBody());
|
||||||
|
|
||||||
@@ -79,8 +79,8 @@ class HomeboxIntegrationTest {
|
|||||||
|
|
||||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
|
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||||
String.class);
|
connectionRequest(wm), String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
||||||
@@ -96,8 +96,8 @@ class HomeboxIntegrationTest {
|
|||||||
|
|
||||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
|
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||||
String.class);
|
connectionRequest(wm), String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
|
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
|
||||||
@@ -113,10 +113,12 @@ class HomeboxIntegrationTest {
|
|||||||
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
|
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
|
||||||
|
|
||||||
ConnectionRequest badRequest = connectionRequest(wm);
|
ConnectionRequest badRequest = connectionRequest(wm);
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
|
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||||
|
badRequest, String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
assertThat(response.getBody())
|
||||||
|
.contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,36 +127,37 @@ class HomeboxIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
|
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
|
||||||
|
|
||||||
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
|
ConnectionRequest emtpyRequest = new ConnectionRequest("", null, "", "", false);
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
|
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||||
|
emtpyRequest, String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test the exception when there is an input for serviceType but it's
|
* Test the exception when there is an input for serviceType but it's unsupported.
|
||||||
* unsupported.
|
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) {
|
void shouldReturnBadRequestWhenServiceTypeIsInvalid(WireMockRuntimeInfo wm) {
|
||||||
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(
|
String body = """
|
||||||
wm.getHttpBaseUrl(),
|
{"appUrl":"%s","serviceType":"wrong-service-type","username":"%s","password":"%s"}
|
||||||
"wrong-service-type",
|
"""
|
||||||
TEST_USER, TEST_PASS,
|
.formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
|
||||||
false);
|
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq,
|
HttpHeaders headers = new HttpHeaders();
|
||||||
String.class);
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
HttpEntity<String> entity = new HttpEntity<>(body, headers);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
ResponseEntity<String> response = restTemplate.exchange(LOGIN.getValue(),
|
||||||
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage());
|
HttpMethod.POST, entity, String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests the succesfull persistance of Homebox credential response to the
|
* Tests the succesfull persistance of Homebox credential response to the database.
|
||||||
* database.
|
|
||||||
*
|
*
|
||||||
* @param wm the WiremockRuntimeInfo object
|
* @param wm the WiremockRuntimeInfo object
|
||||||
*/
|
*/
|
||||||
@@ -166,44 +169,45 @@ class HomeboxIntegrationTest {
|
|||||||
|
|
||||||
ConnectionRequest request = connectionRequest(wm);
|
ConnectionRequest request = connectionRequest(wm);
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
|
ResponseEntity<String> response =
|
||||||
|
restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
|
||||||
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(),
|
||||||
|
request.username());
|
||||||
|
|
||||||
assertThat(dbEntry).isNotNull();
|
assertThat(dbEntry).isNotNull();
|
||||||
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
|
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
|
||||||
assertThat(dbEntry.getUsername()).isEqualTo(request.username());
|
assertThat(dbEntry.getUsername()).isEqualTo(request.username());
|
||||||
|
|
||||||
if (dbEntry instanceof HomeboxEntity hbE) {
|
if (dbEntry instanceof HomeboxConnectionEntity hbE) {
|
||||||
assertThat(hbE.getToken()).isEqualTo("fake-jwt-token");
|
assertThat(hbE.getToken()).isEqualTo("fake-jwt-token");
|
||||||
assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach");
|
assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach");
|
||||||
assertThat(hbE.getExpiresAt().toString()).hasToString("2026-04-26T02:23:13Z");
|
assertThat(hbE.getExpiresAt().toString())
|
||||||
|
.hasToString("2026-04-26T02:23:13Z");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
|
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
|
||||||
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER,
|
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(),
|
||||||
null,
|
HOMEBOX, MOCK_USER, null, false);
|
||||||
false);
|
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials,
|
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||||
String.class);
|
missingCredentials, String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a valid connection request with a mock Api through
|
* Creates a valid connection request with a mock Api through WireMockRuntimeInfo.
|
||||||
* WireMockRuntimeInfo.
|
|
||||||
*
|
*
|
||||||
* @param wm the WiremockRuntimeInfo object
|
* @param wm the WiremockRuntimeInfo object
|
||||||
* @return a mock api connection request.
|
* @return a mock api connection request.
|
||||||
*/
|
*/
|
||||||
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
|
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
|
||||||
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, TEST_PASS,
|
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX, MOCK_USER, MOCK_PASS,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.vaessl.app.connection;
|
|
||||||
|
|
||||||
public final class Mockdata {
|
|
||||||
|
|
||||||
private Mockdata() {}
|
|
||||||
|
|
||||||
public static final String MOCK_URL = "http://localhost:1234";
|
|
||||||
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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.exception.ConnectionNotFoundException;
|
||||||
|
import com.vaessl.app.homebox.HomeboxItemClient;
|
||||||
|
import static com.vaessl.app.shared.ServiceType.HOMEBOX;
|
||||||
|
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class HomeboxSearchProviderTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private HomeboxItemClient client;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private HomeboxSearchProvider provider;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnConnectionNotFoundException() {
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(0, 10);
|
||||||
|
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "", HOMEBOX, false);
|
||||||
|
|
||||||
|
when(client.hbResponse(request, "", pageable)).thenThrow(new ConnectionNotFoundException());
|
||||||
|
|
||||||
|
assertThrows(ConnectionNotFoundException.class,
|
||||||
|
() -> provider.getSearchResults(request, pageable));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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.locationName").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",
|
||||||
|
"aiSearch": false
|
||||||
|
}
|
||||||
|
""")).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, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String searchRequestBody(String serviceType) {
|
||||||
|
return searchRequestBody("http://irrelevant", serviceType, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String searchRequestBody(String appUrl, String serviceType, boolean aiSearch) {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"appUrl": "%s",
|
||||||
|
"query": "Item",
|
||||||
|
"serviceType": "%s",
|
||||||
|
"username": "%s",
|
||||||
|
"aiSearch": "%b"
|
||||||
|
}
|
||||||
|
""".formatted(appUrl, serviceType, MOCK_USER, aiSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"appUrl": "%s",
|
||||||
|
"serviceType": "HOMEBOX",
|
||||||
|
"username": "%s",
|
||||||
|
"password": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
|
||||||
|
class SearchResponseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnNullWhenExtraDataIsNull() {
|
||||||
|
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, null);
|
||||||
|
|
||||||
|
assertThat(response.getExtra(null)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnNullWhenExtraDataKeyIsMissing() {
|
||||||
|
|
||||||
|
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||||
|
|
||||||
|
assertThat(response.getExtra("missing")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnExtraDataValue() {
|
||||||
|
|
||||||
|
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||||
|
|
||||||
|
assertThat(response.id()).isEqualTo(MOCK_ID);
|
||||||
|
assertThat(response.getExtra("key")).contains("value");
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,43 +165,124 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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 official ready-made pgvector docker image but if you already host a PostgreSQL database you need to add the extension yourself.
|
||||||
|
|
||||||
Check the name of your PostGreSQL container:
|
## Installing pgvector on a self-hosted PostgreSQL container
|
||||||
```
|
|
||||||
docker ps
|
pgvector is a PostgreSQL extension that adds a `vector` data type. It does not create a new database — it adds a `vector_store` table to your existing database. The shared library (`vector.so`) must be installed on the PostgreSQL server itself.
|
||||||
|
|
||||||
|
**Do not install it manually inside a running container.** Manual installations do not survive container recreation (e.g. after a `docker compose up --force-recreate` or image update). Instead, bake it into a custom Docker image.
|
||||||
|
|
||||||
|
### Step 1: Create a custom Dockerfile for PostgreSQL
|
||||||
|
|
||||||
|
Create a `Dockerfile.postgres` next to your `docker-compose.yaml`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM postgres:18.4
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates git build-essential postgresql-server-dev-18 \
|
||||||
|
&& git clone --branch v0.8.3 https://github.com/pgvector/pgvector.git \
|
||||||
|
&& cd pgvector && make && make install \
|
||||||
|
&& cd .. && rm -rf pgvector \
|
||||||
|
&& apt-get purge -y ca-certificates git build-essential postgresql-server-dev-18 \
|
||||||
|
&& apt-get autoremove -y \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
```
|
```
|
||||||
|
|
||||||
Enter your container via bash:
|
- `FROM postgres:18.4` — this is still the standard postgres image, not the pgvector image
|
||||||
|
- `ca-certificates` — required for git to verify GitHub's SSL certificate during build
|
||||||
|
- `postgresql-server-dev-18` — provides the PostgreSQL header files needed to compile pgvector
|
||||||
|
|
||||||
```
|
### Step 2: Update docker-compose.yaml to use the custom image
|
||||||
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 .
|
```yaml
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
container_name: postgres
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.postgres
|
||||||
|
network: host
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
networks:
|
||||||
|
- pg_network
|
||||||
|
volumes:
|
||||||
|
- /home/pi/docker/postgresql:/var/lib/postgresql
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pg_network:
|
||||||
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
Install dependencies, build and install pgvector:
|
### Step 3: Build the image and recreate the container
|
||||||
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;
|
Before recreating, back up your databases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it <container-name> bash
|
||||||
|
su - postgres -c "pg_dumpall > /tmp/backup.sql"
|
||||||
|
exit
|
||||||
|
docker cp <container-name>:/tmp/backup.sql .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Build the image. Use `--network=host` to ensure the build container can reach GitHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --network=host -t postgres-pgvector -f Dockerfile.postgres .
|
||||||
|
```
|
||||||
|
|
||||||
|
Recreate the container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Enable the extensions in each database
|
||||||
|
|
||||||
|
Run this once per database that needs pgvector:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it <container-name> psql -U <db-user> -d <db-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
```
|
||||||
|
|
||||||
|
`uuid-ossp` is also required — Spring AI's `vector_store` table uses `uuid_generate_v4()` for its primary key.
|
||||||
|
|
||||||
|
### Step 5: Add pgvector config to application.yaml
|
||||||
|
|
||||||
|
Spring AI's pgvector auto-configuration requires these properties:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
ai:
|
||||||
|
vectorstore:
|
||||||
|
pgvector:
|
||||||
|
dimensions: 1536 # must match your embedding model output size
|
||||||
|
distance-type: COSINE_DISTANCE
|
||||||
|
index-type: HNSW
|
||||||
|
initialize-schema: true # auto-creates the vector_store table on startup
|
||||||
|
```
|
||||||
|
|
||||||
|
`dimensions` depends on the embedding model:
|
||||||
|
- `text-embedding-ada-002` / `text-embedding-3-small` → 1536
|
||||||
|
- `text-embedding-3-large` → 3072
|
||||||
|
|
||||||
|
`initialize-schema: true` means Spring AI will create the `vector_store` table automatically on first startup — no manual SQL needed.
|
||||||
|
|
||||||
# Appendix: Additional config for developing in Code-Server
|
# Appendix: Additional config for developing in Code-Server
|
||||||
|
|
||||||
When using the code-server container there are additional config steps to mind:
|
When using the code-server container there are additional config steps to mind:
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ The entire commit can be reviewed under following hash 43bbcece7a901e94021e10bca
|
|||||||
- DELETE /connections/{serviceType} — removes the specific key from the session. If no connections remain, the session is invalidated entirely.
|
- DELETE /connections/{serviceType} — removes the specific key from the session. If no connections remain, the session is invalidated entirely.
|
||||||
|
|
||||||
---
|
---
|
||||||
**connection/HomeBoxConnectionProvider.java (modified)**
|
**connection/HomeboxConnectionProvider.java (modified)**
|
||||||
|
|
||||||
Implements the new interface methods:
|
Implements the new interface methods:
|
||||||
- checkCredentials validates that username and password are present before touching the network.
|
- checkCredentials validates that username and password are present before touching the network.
|
||||||
|
|||||||
@@ -94,11 +94,11 @@ public interface ConnectionProvider {
|
|||||||
- `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update
|
- `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
|
- `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag
|
||||||
|
|
||||||
***HomeBoxConnectionProvider.java***
|
***HomeboxConnectionProvider.java***
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@Component
|
@Component
|
||||||
public class HomeBoxConnectionProvider implements ConnectionProvider {
|
public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getServiceType() { return "HOMEBOX"; }
|
public String getServiceType() { return "HOMEBOX"; }
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>Vaessl ~ AI Bridge</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { Dashboard } from './components/Dashboard'
|
import { Dashboard } from './components/connections/Dashboard'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiFetch } from './client'
|
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) =>
|
export const login = (req: LoginRequest) =>
|
||||||
apiFetch<AuthResponse>('/login', { method: 'POST', body: JSON.stringify(req) })
|
apiFetch<AuthResponse>('/login', { method: 'POST', body: JSON.stringify(req) })
|
||||||
@@ -7,5 +7,5 @@ export const login = (req: LoginRequest) =>
|
|||||||
export const getStatuses = () =>
|
export const getStatuses = () =>
|
||||||
apiFetch<ConnectionStatus[]>('/connections/status')
|
apiFetch<ConnectionStatus[]>('/connections/status')
|
||||||
|
|
||||||
export const logout = (serviceType: string) =>
|
export const logout = (serviceType: ServiceType) =>
|
||||||
apiFetch<void>(`/connections/${serviceType}`, { method: 'DELETE' })
|
apiFetch<void>(`/connections/${serviceType}`, { method: 'DELETE' })
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { apiFetch } from "./client";
|
||||||
|
import type {
|
||||||
|
PagedSearchResponse,
|
||||||
|
SearchRequest,
|
||||||
|
ServiceItem,
|
||||||
|
} from "../types/search";
|
||||||
|
|
||||||
|
export const search = (req: SearchRequest) =>
|
||||||
|
apiFetch<PagedSearchResponse<ServiceItem>>("/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 { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||||
import { login } from '../api/connections'
|
import { login } from '../../api/connections'
|
||||||
import type { LoginRequest } from '../types/connection'
|
import type { LoginRequest, ServiceType } from '../../types/connection'
|
||||||
import './ConnectModal.scss'
|
import '../ui/Modal.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serviceType: string
|
serviceType: ServiceType
|
||||||
label: string
|
label: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSuccess: () => 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;
|
display: flex;
|
||||||
gap: 8px;
|
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'
|
import './ServiceCard.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serviceType: string
|
serviceType: ServiceType
|
||||||
label: string
|
label: string
|
||||||
icon: string
|
icon: string
|
||||||
status: ConnectionStatus | null
|
status: ConnectionStatus | null
|
||||||
onConnect: () => void
|
onConnect: () => void
|
||||||
onDisconnect: () => 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 connected = status?.connected ?? false
|
||||||
|
|
||||||
const formatExpiry = (iso: string | null) => {
|
const formatExpiry = (iso: string | null) => {
|
||||||
@@ -37,15 +39,10 @@ export function ServiceCard({ serviceType: _serviceType, label, icon, status, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="service-card__actions">
|
<div className="service-card__actions">
|
||||||
{connected ? (
|
<ActionButton variant={connected ? 'disconnect' : 'connect'} onClick={connected ? onDisconnect : onConnect}>
|
||||||
<button className="service-card__btn service-card__btn--disconnect" onClick={onDisconnect}>
|
{connected ? 'Disconnect' : 'Connect'}
|
||||||
Disconnect
|
</ActionButton>
|
||||||
</button>
|
{connected && (<ActionButton variant='search' onClick={onSearch}>Search</ActionButton>)}
|
||||||
) : (
|
|
||||||
<button className="service-card__btn service-card__btn--connect" onClick={onConnect}>
|
|
||||||
Connect
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||||
|
import '../ui/Modal.scss'
|
||||||
|
import { type PagedSearchResponse, type ServiceItem, 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<ServiceItem> | null>(null)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
//TODO: implement aiSearch
|
||||||
|
const [aiSearch, setAiSearch] = useState(false);
|
||||||
|
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, aiSearch}
|
||||||
|
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%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -87,7 +89,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: rgba(239, 68, 68, 0.08);
|
background: rgba(11, 0, 0, 0.499);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
@@ -110,4 +112,44 @@
|
|||||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
&:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
&: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 {
|
export interface LoginRequest {
|
||||||
appUrl: string
|
appUrl: string;
|
||||||
serviceType: string
|
serviceType: ServiceType;
|
||||||
username: string
|
username: string;
|
||||||
password: string
|
password: string;
|
||||||
stayLoggedIn: boolean
|
stayLoggedIn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
serviceType: string
|
serviceType: ServiceType;
|
||||||
expiresAt: string
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectionStatus {
|
export interface ConnectionStatus {
|
||||||
serviceType: string
|
serviceType: ServiceType;
|
||||||
appUrl: string
|
appUrl: string;
|
||||||
username: string
|
username: string;
|
||||||
expiresAt: string | null
|
expiresAt: string | null;
|
||||||
connected: boolean
|
connected: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ServiceType } from "./connection";
|
||||||
|
|
||||||
|
export interface SearchRequest {
|
||||||
|
appUrl: string;
|
||||||
|
serviceType: ServiceType;
|
||||||
|
username: string;
|
||||||
|
query: string | null;
|
||||||
|
aiSearch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceItem {
|
||||||
|
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