From 8288e2688c886c59dff728feb618bc3e375498de Mon Sep 17 00:00:00 2001 From: saba_zedginidze Date: Fri, 23 Aug 2024 17:30:01 +0400 Subject: [PATCH] [MODINVSTOR-1243] Implement endpoint to retrieve items from multiple tenants --- descriptors/ModuleDescriptor-template.json | 8 ++ ramls/inventory.raml | 10 ++ ramls/tenantItemPair.json | 18 +++ ramls/tenantItemPairCollection.json | 17 +++ .../folio/inventory/InventoryVerticle.java | 2 + .../org/folio/inventory/resources/Items.java | 4 +- .../inventory/resources/TenantItems.java | 117 ++++++++++++++++++ 7 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 ramls/tenantItemPair.json create mode 100644 ramls/tenantItemPairCollection.json create mode 100644 src/main/java/org/folio/inventory/resources/TenantItems.java diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 4b65f2502..2d64de3eb 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -365,6 +365,14 @@ "inventory-storage.instances.item.get" ] }, + { + "methods": ["GET"], + "pathPattern": "/inventory/tenant-items", + "permissionsRequired": ["inventory.items.collection.get"], + "modulePermissions": [ + "inventory-storage.items.collection.get" + ] + }, { "methods": ["GET"], "pathPattern": "/inventory/instances/{id}", diff --git a/ramls/inventory.raml b/ramls/inventory.raml index 97a7bda50..f3f71a0a0 100644 --- a/ramls/inventory.raml +++ b/ramls/inventory.raml @@ -15,6 +15,7 @@ types: holdings: !include holdings-record.json instance: !include instance.json instances: !include instances.json + tenantItemPairCollection: !include tenantItemPairCollection.json traits: language: !include raml-util/traits/language.raml @@ -294,6 +295,15 @@ resourceTypes: Possible values of the 'relations' parameter are: 'onlyBoundWiths', 'onlyBoundWithsSkipDirectlyLinkedItem'", example: "holdingsRecordId==\"[UUID]\""} ] + /tenant-items: + displayName: Fetch items based on tenant IDs + post: + body: + application/json: + type: tenantItemPairCollection + responses: + 200: + description: "Fetched items based on tenant IDs" /holdings/{holdingsId}: put: description: Update Holdings by holdingsId diff --git a/ramls/tenantItemPair.json b/ramls/tenantItemPair.json new file mode 100644 index 000000000..15b18b7e7 --- /dev/null +++ b/ramls/tenantItemPair.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Pair of item and tenant IDs", + "type": "object", + "properties": { + "itemId": { + "type": "string", + "description": "Unique ID (UUID) of the item", + "$ref": "../../mod-inventory-storage/ramls/uuid.json" + }, + "tenantId": { + "type": "string", + "description": "Unique ID of the tenant where the item is located" + } + }, + "additionalProperties": false, + "required": ["itemId", "tenantId"] +} diff --git a/ramls/tenantItemPairCollection.json b/ramls/tenantItemPairCollection.json new file mode 100644 index 000000000..b1cf58ae1 --- /dev/null +++ b/ramls/tenantItemPairCollection.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Collection of pairs of item and tenant IDs", + "type": "object", + "properties": { + "itemTenantPairs": { + "type": "array", + "description": "Unique ID (UUID) of the item", + "items": { + "type": "object", + "$ref": "tenantItemPair.json" + } + } + }, + "additionalProperties": false, + "required": ["itemTenantPairs"] +} diff --git a/src/main/java/org/folio/inventory/InventoryVerticle.java b/src/main/java/org/folio/inventory/InventoryVerticle.java index 80f2bdae9..d76f01ea0 100644 --- a/src/main/java/org/folio/inventory/InventoryVerticle.java +++ b/src/main/java/org/folio/inventory/InventoryVerticle.java @@ -19,6 +19,7 @@ import org.folio.inventory.resources.ItemsByHoldingsRecordId; import org.folio.inventory.resources.MoveApi; import org.folio.inventory.resources.TenantApi; +import org.folio.inventory.resources.TenantItems; import org.folio.inventory.resources.UpdateOwnershipApi; import org.folio.inventory.storage.Storage; @@ -71,6 +72,7 @@ public void start(Promise started) { new InventoryConfigApi().register(router); new TenantApi().register(router); new UpdateOwnershipApi(storage, client, consortiumService).register(router); + new TenantItems(storage, client).register(router); Handler> onHttpServerStart = result -> { if (result.succeeded()) { diff --git a/src/main/java/org/folio/inventory/resources/Items.java b/src/main/java/org/folio/inventory/resources/Items.java index 17c1ac4a4..a3f19a79c 100644 --- a/src/main/java/org/folio/inventory/resources/Items.java +++ b/src/main/java/org/folio/inventory/resources/Items.java @@ -508,7 +508,7 @@ private OkapiHttpClient createHttpClient( exception.toString()))); } - private CollectionResourceClient createItemsStorageClient( + protected CollectionResourceClient createItemsStorageClient( OkapiHttpClient client, WebContext context) throws MalformedURLException { @@ -718,7 +718,7 @@ private void respondWithItemRepresentation ( }); } - private void invalidOkapiUrlResponse(RoutingContext routingContext, WebContext context) { + protected void invalidOkapiUrlResponse(RoutingContext routingContext, WebContext context) { ServerErrorResponse.internalError(routingContext.response(), String.format("Invalid Okapi URL: %s", context.getOkapiLocation())); } diff --git a/src/main/java/org/folio/inventory/resources/TenantItems.java b/src/main/java/org/folio/inventory/resources/TenantItems.java new file mode 100644 index 000000000..7aa2e813c --- /dev/null +++ b/src/main/java/org/folio/inventory/resources/TenantItems.java @@ -0,0 +1,117 @@ +package org.folio.inventory.resources; + +import static java.lang.String.format; +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.mapping; +import static java.util.stream.Collectors.toList; +import static org.folio.inventory.support.CqlHelper.multipleRecordsCqlQuery; + +import java.lang.invoke.MethodHandles; +import java.net.MalformedURLException; +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.inventory.common.WebContext; +import org.folio.inventory.storage.Storage; +import org.folio.inventory.storage.external.CollectionResourceClient; +import org.folio.inventory.support.JsonArrayHelper; +import org.folio.inventory.support.http.client.OkapiHttpClient; +import org.folio.inventory.support.http.client.Response; +import org.folio.inventory.support.http.server.JsonResponse; +import org.folio.inventory.support.http.server.ServerErrorResponse; + +import io.vertx.core.http.HttpClient; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.client.WebClient; + +public class TenantItems extends Items { + + private static final Logger log = LogManager.getLogger(MethodHandles.lookup().lookupClass()); + + private static final String TENANT_ITEMS_PATH = "/inventory/tenant-items"; + private static final String TENANT_ITEM_PAIRS_FIELD = "itemTenantPairs"; + private static final String ITEMS_FIELD = "items"; + private static final String TOTAL_RECORDS_FIELD = "items"; + private static final String ITEM_ID_FIELD = "itemId"; + private static final String TENANT_ID_FIELD = "tenantId"; + + public TenantItems(final Storage storage, final HttpClient client) { + super(storage, client); + } + + @Override + public void register(Router router) { + router.post(TENANT_ITEMS_PATH).handler(this::getItemsFromTenants); + } + + /** + * This API is meant to be used by UI to fetch different items from several + * tenants together within one call + * + */ + private void getItemsFromTenants(RoutingContext routingContext) { + var getItemsFutures = JsonArrayHelper.toList(routingContext.body().asJsonObject(), TENANT_ITEM_PAIRS_FIELD).stream() + .collect(groupingBy(json -> json.getString(TENANT_ID_FIELD), mapping(json -> json.getString(ITEM_ID_FIELD), toList()))) + .entrySet().stream() + .map(tenantToItems -> getItemsWithTenantId(tenantToItems.getKey(), tenantToItems.getValue(), routingContext)) + .toList(); + + CompletableFuture.allOf(getItemsFutures.toArray(new CompletableFuture[0])) + .thenApply(v -> getItemsFutures.stream() + .map(CompletableFuture::join) + .flatMap(List::stream) + .collect(toList())) + .thenApply(this::constructResponse) + .thenAccept(jsonObject -> JsonResponse.success(routingContext.response(), jsonObject)); + } + + private CompletableFuture> getItemsWithTenantId(String tenantId, List itemIds, RoutingContext routingContext) { + log.info("getItemsWithTenantId:: Fetching items - [{}] from tenant - {}", itemIds, tenantId); + var context = new WebContext(routingContext); + CollectionResourceClient itemsStorageClient; + try { + OkapiHttpClient okapiClient = createHttpClient(tenantId, context, routingContext); + itemsStorageClient = createItemsStorageClient(okapiClient, context); + } + catch (MalformedURLException e) { + invalidOkapiUrlResponse(routingContext, context); + return CompletableFuture.completedFuture(List.of()); + } + + var getByIdsQuery = multipleRecordsCqlQuery(itemIds); + var itemsFetched = new CompletableFuture(); + itemsStorageClient.getAll(getByIdsQuery, itemsFetched::complete); + + return itemsFetched.thenApplyAsync(response -> + response.getStatusCode() == 200 && response.hasBody() + ? JsonArrayHelper.toList(response.getJson(), ITEMS_FIELD) + : List.of()); + } + + private JsonObject constructResponse(List items) { + return JsonObject.of( + ITEMS_FIELD, JsonArray.of(items.toArray()), + TOTAL_RECORDS_FIELD, items.size() + ); + } + + private OkapiHttpClient createHttpClient(String tenantId, WebContext context, + RoutingContext routingContext) throws MalformedURLException { + return new OkapiHttpClient(WebClient.wrap(client), + URI.create(context.getOkapiLocation()).toURL(), + Optional.ofNullable(tenantId).orElse(context.getTenantId()), + context.getToken(), + context.getUserId(), + context.getRequestId(), + exception -> ServerErrorResponse.internalError(routingContext.response(), + format("Failed to contact storage module: %s", exception.toString()))); + } + +}