diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java index a20d8bf..4815b39 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -2,12 +2,10 @@ package com.vaessl.app.connection; import java.time.Instant; -public interface ConnectionProvider { +public interface ConnectionProvider extends ServiceProvider { void checkCredentials(ConnectionRequest request); - String getServiceType(); - ConnectionResponse authenticate(ConnectionRequest request); ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request); diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java index 0960c26..f52535a 100644 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java @@ -1,7 +1,8 @@ package com.vaessl.app.connection; public enum Endpoint { - HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS("/connections/status"); + HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS( + "/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/items"); private final String value; diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java index 6e86b8d..7685008 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxConnectionProvider.java @@ -65,7 +65,11 @@ public class HomeboxConnectionProvider implements ConnectionProvider { 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 diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java b/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java new file mode 100644 index 0000000..b5af34f --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ServiceProvider.java @@ -0,0 +1,10 @@ +package com.vaessl.app.connection; + +public interface ServiceProvider { + + /** + * Returns the service type key used to look up this provider in a registry, e.g. + * {@code "HOMEBOX"}. + */ + String getServiceType(); +} diff --git a/backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java b/backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java new file mode 100644 index 0000000..d02d713 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/ConnectionNotFoundException.java @@ -0,0 +1,4 @@ +package com.vaessl.app.exception; + +public class ConnectionNotFoundException extends RuntimeException { +} diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java index 17b27ff..f4013b1 100644 --- a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java @@ -11,7 +11,12 @@ public enum ErrorMessage { HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."), SERVER_ERROR_GENERAL( "The external app returned a server error: "), WRONG_SERVICE_TYPE( - HttpStatus.NOT_FOUND, "No such service type."); + HttpStatus.NOT_FOUND, + "No such service type."), CONNECTION_NOT_FOUND( + HttpStatus.NOT_FOUND, + "No active connection found for this service."), REMOTE_API_EMPTY_RESPONSE( + HttpStatus.BAD_GATEWAY, + "Remote API returned empty response for "); private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java index f7fa664..3adbd51 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -59,6 +59,11 @@ public class GlobalExceptionHandler { BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields()); } + @ExceptionHandler(ConnectionNotFoundException.class) + public ProblemDetail handleConnectionNotFound(ConnectionNotFoundException e) { + return ProblemDetail.forStatusAndDetail(CONNECTION_NOT_FOUND.getStatus(), CONNECTION_NOT_FOUND.getMessage()); + } + @ExceptionHandler(RemoteApiException.class) public ProblemDetail handleRemoteApiException(RemoteApiException e) { return ProblemDetail.forStatusAndDetail(REMOTE_API_EMPTY_RESPONSE.getStatus(), diff --git a/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java new file mode 100644 index 0000000..ee490ae --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxSearchProvider.java @@ -0,0 +1,79 @@ +package com.vaessl.app.search; + +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.vaessl.app.connection.ConnectionEntity; +import com.vaessl.app.connection.ConnectionRepository; +import com.vaessl.app.connection.HomeboxEntity; +import com.vaessl.app.exception.ConnectionNotFoundException; +import com.vaessl.app.exception.RemoteApiException; + +import static com.vaessl.app.connection.Endpoint.*; + +@Component +public class HomeboxSearchProvider implements SearchProvider { + + private final RestClient.Builder restClientBuilder; + + private final ConnectionRepository cRepository; + + public HomeboxSearchProvider(RestClient.Builder restClientBuilder, + ConnectionRepository cRepository) { + this.restClientBuilder = restClientBuilder; + this.cRepository = cRepository; + } + + @Override + public String getServiceType() { + return "HOMEBOX"; + } + + @Override + public Page getSearchResults(SearchRequest request, Pageable pageable) { + + ConnectionEntity entity = + cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); + + if (!(entity instanceof HomeboxEntity hbEntity)) { + throw new ConnectionNotFoundException(); + } + + HomeboxSearchResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().get() + .uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue()) + .queryParam("q", request.query()) + .queryParam("page", pageable.getPageNumber() + 1) + .queryParam("pageSize", pageable.getPageSize()).build()) + .headers(h -> h.setBearerAuth( + hbEntity.getToken())).retrieve().body(HomeboxSearchResponse.class); + + if (hbResponse == null) { + throw new RemoteApiException(request.appUrl()); + } + + List items = hbResponse.items().stream().map(i -> { + String title = i.name(); + String description = i.description(); + Map extraSearchResponseData = Map.of("location", i.location()); + return new SearchResponse(title, description, extraSearchResponseData); + }).toList(); + + return new PageImpl<>(items, pageable, hbResponse.total()); + } + + private record HomeboxSearchResponse(int page, int pageSize, int total, + List items) { + } + + private record HomeboxItem(String name, String description, HomeboxLocation location) { + } + + private record HomeboxLocation(String name, String description) { + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java b/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java new file mode 100644 index 0000000..9da5d55 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java @@ -0,0 +1,14 @@ +package com.vaessl.app.search; + +import java.util.List; +import org.springframework.data.domain.Page; + +public record PagedSearchResponse(List content, int page, int pageSize, long totalElements, + boolean first, boolean last, String sort) { + + public static PagedSearchResponse from(Page pageResult) { + return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(), + pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(), + pageResult.isLast(), pageResult.getSort().toString()); + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchController.java b/backend/src/main/java/com/vaessl/app/search/SearchController.java new file mode 100644 index 0000000..e7d4ef1 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchController.java @@ -0,0 +1,41 @@ +package com.vaessl.app.search; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class SearchController { + + private final SearchService searchService; + + /** + * Executes a paged search against the requested service. Returns {@code 401 Unauthorized} if + * there is no active session. + */ + @PostMapping("/search") + public ResponseEntity> search( + @Valid @RequestBody SearchRequest request, + @PageableDefault(size = 20) Pageable pageable, HttpServletRequest httpReq) { + HttpSession session = httpReq.getSession(false); + if (session == null + || session.getAttribute(request.serviceType() + "_CONNECTION_ID") == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + Page result = searchService.search(request, pageable); + + return ResponseEntity.ok(PagedSearchResponse.from(result)); + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchProvider.java b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java new file mode 100644 index 0000000..906a544 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchProvider.java @@ -0,0 +1,20 @@ +package com.vaessl.app.search; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.vaessl.app.connection.ServiceProvider; + +/** + * Implemented by any service that supports querying its remote API for items. + */ +public interface SearchProvider extends ServiceProvider { + + /** + * Executes a search query against the remote service and returns matching results. + * + * @param request the search request containing the query string, app URL, and user credentials + * @return a list of {@link SearchResponse} items matching the query + */ + Page getSearchResults(SearchRequest request, Pageable pageable); +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchRequest.java b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java new file mode 100644 index 0000000..5019db7 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java @@ -0,0 +1,7 @@ +package com.vaessl.app.search; + +import jakarta.validation.constraints.NotBlank; + +public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query, + @NotBlank String serviceType) { +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchResponse.java b/backend/src/main/java/com/vaessl/app/search/SearchResponse.java new file mode 100644 index 0000000..9257fd8 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchResponse.java @@ -0,0 +1,16 @@ +package com.vaessl.app.search; + +import java.util.Map; + +public record SearchResponse(String title, String description, Map extraData) { + + public String getExtra(String key) { + if (extraData == null) { + return null; + } else { + Object value = extraData.get(key); + + return value != null ? String.valueOf(value) : null; + } + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/SearchService.java b/backend/src/main/java/com/vaessl/app/search/SearchService.java new file mode 100644 index 0000000..f195f39 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/SearchService.java @@ -0,0 +1,42 @@ +package com.vaessl.app.search; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import com.vaessl.app.exception.WrongServiceTypeException; + +@Service +public class SearchService { + + private final Map providerRegistry; + + public SearchService(List providers) { + this.providerRegistry = Map.copyOf(providers.stream() + .collect(Collectors.toMap(SearchProvider::getServiceType, p -> p))); + } + + /** + * Dispatches the paged search request to the provider registered for + * {@link SearchRequest#serviceType()}. + * + * @param request the search request + * @param pageable the Pageable interface + * @return results returned by the matching provider + * @throws WrongServiceTypeException if no provider is registered for the given service type + */ + public Page search(SearchRequest request, Pageable pageable) { + + SearchProvider provider = providerRegistry.get(request.serviceType()); + + if (provider == null) { + throw new WrongServiceTypeException(); + } + + return provider.getSearchResults(request, pageable); + } +}