Skip to content

Commit

Permalink
[MODINVSTOR-1243] Implement endpoint to retrieve items from multiple …
Browse files Browse the repository at this point in the history
…tenants (#755)

* [MODINVSTOR-1243] Implement endpoint to retrieve items from multiple tenants
  • Loading branch information
Saba-Zedginidze-EPAM authored Aug 28, 2024
1 parent af5b69d commit 6d09dae
Show file tree
Hide file tree
Showing 11 changed files with 333 additions and 1 deletion.
14 changes: 14 additions & 0 deletions descriptors/ModuleDescriptor-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,14 @@
"inventory-storage.instances.item.get"
]
},
{
"methods": ["POST"],
"pathPattern": "/inventory/tenant-items",
"permissionsRequired": ["inventory.tenant-items.collection.get"],
"modulePermissions": [
"inventory-storage.items.collection.get"
]
},
{
"methods": ["GET"],
"pathPattern": "/inventory/instances/{id}",
Expand Down Expand Up @@ -681,6 +689,11 @@
"displayName": "Inventory - get item collection",
"description": "Get item collection"
},
{
"permissionName": "inventory.tenant-items.collection.get",
"displayName": "Inventory - get item collection from multiple tenants",
"description": "Get item collection from multiple tenants"
},
{
"permissionName": "inventory.items.collection.delete",
"displayName": "Inventory - delete entire item collection",
Expand Down Expand Up @@ -828,6 +841,7 @@
"description": "Entire set of permissions needed to use the inventory",
"subPermissions": [
"inventory.items.collection.get",
"inventory.tenant-items.collection.get",
"inventory.items.item.get",
"inventory.items.item.post",
"inventory.items.item.put",
Expand Down
2 changes: 2 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,8 @@
<path>${basedir}/ramls/items_update_ownership.json</path>
<path>${basedir}/ramls/update_ownership_response.json</path>
<path>${basedir}/ramls/instance-ingress-event.json</path>
<path>${basedir}/ramls/tenantItemPair.json</path>
<path>${basedir}/ramls/tenantItemPairCollection.json</path>
</sourcePaths>
<targetPackage>org.folio</targetPackage>
<generateBuilders>true</generateBuilders>
Expand Down
13 changes: 13 additions & 0 deletions ramls/inventory.raml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -294,6 +295,18 @@ 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"
body:
application/json:
type: items
/holdings/{holdingsId}:
put:
description: Update Holdings by holdingsId
Expand Down
18 changes: 18 additions & 0 deletions ramls/tenantItemPair.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Pair of item and tenant IDs",
"type": "object",
"properties": {
"tenantId": {
"type": "string",
"description": "Unique ID of the tenant where the item is located"
},
"itemId": {
"type": "string",
"description": "Unique ID (UUID) of the item",
"$ref": "uuid.json"
}
},
"additionalProperties": false,
"required": ["itemId", "tenantId"]
}
17 changes: 17 additions & 0 deletions ramls/tenantItemPairCollection.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Collection of pairs of item and tenant IDs",
"type": "object",
"properties": {
"tenantItemPairs": {
"type": "array",
"description": "Pairs of tenantId and itemId",
"items": {
"type": "object",
"$ref": "tenantItemPair.json"
}
}
},
"additionalProperties": false,
"required": ["tenantItemPairs"]
}
2 changes: 2 additions & 0 deletions src/main/java/org/folio/inventory/InventoryVerticle.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -71,6 +72,7 @@ public void start(Promise<Void> started) {
new InventoryConfigApi().register(router);
new TenantApi().register(router);
new UpdateOwnershipApi(storage, client, consortiumService).register(router);
new TenantItems(client).register(router);

Handler<AsyncResult<HttpServer>> onHttpServerStart = result -> {
if (result.succeeded()) {
Expand Down
142 changes: 142 additions & 0 deletions src/main/java/org/folio/inventory/resources/TenantItems.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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.net.URL;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;

import org.apache.http.HttpStatus;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.folio.TenantItemPair;
import org.folio.TenantItemPairCollection;
import org.folio.inventory.common.WebContext;
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;
import io.vertx.ext.web.handler.BodyHandler;

/**
* Resource that allows to get Inventory items from multiple tenants at once.
* User should have an affiliation in order to be able to retrieve items from the corresponding tenant.
*/
public class TenantItems {

private static final Logger LOG = LogManager.getLogger(MethodHandles.lookup().lookupClass());

private static final String TENANT_ITEMS_PATH = "/inventory/tenant-items";
public static final String ITEMS_FIELD = "items";
public static final String TOTAL_RECORDS_FIELD = "totalRecords";
public static final String TENANT_ID_FIELD = "tenantId";

private final HttpClient client;

public TenantItems(HttpClient client) {
this.client = client;
}

public void register(Router router) {
router.post(TENANT_ITEMS_PATH + "*").handler(BodyHandler.create());
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 = routingContext.body().asPojo(TenantItemPairCollection.class)
.getTenantItemPairs().stream()
.collect(groupingBy(TenantItemPair::getTenantId, mapping(TenantItemPair::getItemId, 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)
.toList())
.thenApply(this::constructResponse)
.thenAccept(jsonObject -> JsonResponse.success(routingContext.response(), jsonObject));
}

private CompletableFuture<List<JsonObject>> getItemsWithTenantId(String tenantId, List<String> 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<Response>();
itemsStorageClient.getAll(getByIdsQuery, itemsFetched::complete);

return itemsFetched.thenApplyAsync(response ->
getItemsWithTenantId(tenantId, response));
}

private List<JsonObject> getItemsWithTenantId(String tenantId, Response response) {
if (response.getStatusCode() != HttpStatus.SC_OK || !response.hasBody()) {
return List.of();
}
return JsonArrayHelper.toList(response.getJson(), ITEMS_FIELD).stream()
.map(item -> item.put(TENANT_ID_FIELD, tenantId))
.toList();
}

private JsonObject constructResponse(List<JsonObject> items) {
return JsonObject.of(
ITEMS_FIELD, JsonArray.of(items.toArray()),
TOTAL_RECORDS_FIELD, items.size()
);
}

private CollectionResourceClient createItemsStorageClient(OkapiHttpClient client, WebContext context) throws MalformedURLException {
return new CollectionResourceClient(client, new URL(context.getOkapiLocation() + "/item-storage/items"));
}

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())));
}

private void invalidOkapiUrlResponse(RoutingContext routingContext, WebContext context) {
ServerErrorResponse.internalError(routingContext.response(),
String.format("Invalid Okapi URL: %s", context.getOkapiLocation()));
}

}
4 changes: 3 additions & 1 deletion src/test/java/api/ApiTestSuite.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import api.items.MarkItemUnavailableApiTests;
import api.items.MarkItemUnknownApiTests;
import api.items.MarkItemWithdrawnApiTests;
import api.items.TenantItemApiTests;
import api.support.ControlledVocabularyPreparation;
import api.support.http.ResourceClient;
import io.vertx.core.json.JsonArray;
Expand Down Expand Up @@ -72,7 +73,8 @@
AdminApiTest.class,
InventoryConfigApiTest.class,
HoldingsUpdateOwnershipApiTest.class,
ItemUpdateOwnershipApiTest.class
ItemUpdateOwnershipApiTest.class,
TenantItemApiTests.class
})
public class ApiTestSuite {
public static final int INVENTORY_VERTICLE_TEST_PORT = 9603;
Expand Down
107 changes: 107 additions & 0 deletions src/test/java/api/items/TenantItemApiTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package api.items;

import static api.ApiTestSuite.COLLEGE_TENANT_ID;
import static api.ApiTestSuite.CONSORTIA_TENANT_ID;
import static api.ApiTestSuite.getBookMaterialType;
import static api.ApiTestSuite.getCanCirculateLoanType;
import static api.support.InstanceSamples.smallAngryPlanet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.folio.inventory.resources.TenantItems.ITEMS_FIELD;
import static org.folio.inventory.resources.TenantItems.TENANT_ID_FIELD;
import static org.folio.inventory.resources.TenantItems.TOTAL_RECORDS_FIELD;
import static org.folio.inventory.support.ItemUtil.ID;

import java.net.MalformedURLException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import org.folio.TenantItemPair;
import org.folio.TenantItemPairCollection;
import org.folio.inventory.support.JsonArrayHelper;
import org.folio.inventory.support.http.client.OkapiHttpClient;
import org.folio.inventory.support.http.client.Response;
import org.junit.Test;
import org.junit.runner.RunWith;

import api.support.ApiRoot;
import api.support.ApiTests;
import api.support.InstanceApiClient;
import api.support.builders.HoldingRequestBuilder;
import api.support.http.ResourceClient;
import io.vertx.core.json.JsonObject;
import junitparams.JUnitParamsRunner;

@RunWith(JUnitParamsRunner.class)
public class TenantItemApiTests extends ApiTests {

@Test
public void testTenantItemsGetFromDifferentTenants() throws MalformedURLException,
ExecutionException, InterruptedException, TimeoutException {

var consortiumItemId = createConsortiumInstanceHoldingItem();
var collegeItemId = createCollegeInstanceHoldingItem();
var consortiumItem = consortiumItemsClient.getById(consortiumItemId).getJson();
var collegeItem = collegeItemsClient.getById(collegeItemId).getJson();

assertThat(consortiumItem.getString(ID)).matches(consortiumItemId.toString());
assertThat(collegeItem.getString(ID)).matches(collegeItemId.toString());

var tenantItemPairCollection = constructTenantItemPairCollection(Map.of(
CONSORTIA_TENANT_ID, consortiumItem.getString(ID),
COLLEGE_TENANT_ID, collegeItem.getString(ID)
));

var response = okapiClient.post(ApiRoot.tenantItems(), JsonObject.mapFrom(tenantItemPairCollection))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
assertThat(response.getStatusCode()).isEqualTo(200);

consortiumItem.put(TENANT_ID_FIELD, CONSORTIA_TENANT_ID);
collegeItem.put(TENANT_ID_FIELD, COLLEGE_TENANT_ID);
var items = extractItems(response, 2);
assertThat(items).contains(consortiumItem, collegeItem);
}

private UUID createConsortiumInstanceHoldingItem() {
return createInstanceHoldingItem(consortiumItemsClient, consortiumHoldingsStorageClient, consortiumOkapiClient);
}

private UUID createCollegeInstanceHoldingItem() {
return createInstanceHoldingItem(collegeItemsClient, collegeHoldingsStorageClient, collegeOkapiClient);
}

private UUID createInstanceHoldingItem(ResourceClient itemsStorageClient, ResourceClient holdingsStorageClient, OkapiHttpClient okapiHttpClient) {
var instanceId = UUID.randomUUID();
InstanceApiClient.createInstance(okapiHttpClient, smallAngryPlanet(instanceId));
var holdingId = holdingsStorageClient.create(new HoldingRequestBuilder()
.forInstance(instanceId)).getId();
var itemId = UUID.randomUUID();
var newItemRequest = JsonObject.of(
"id", itemId.toString(),
"status", new JsonObject().put("name", "Available"),
"holdingsRecordId", holdingId,
"materialTypeId", getBookMaterialType(),
"permanentLoanTypeId", getCanCirculateLoanType());
itemsStorageClient.create(newItemRequest);
return itemId;
}

private List<JsonObject> extractItems(Response itemsResponse, int expected) {
var itemsCollection = itemsResponse.getJson();
var items = JsonArrayHelper.toList(itemsCollection.getJsonArray(ITEMS_FIELD));
assertThat(items).hasSize(expected);
assertThat(itemsCollection.getInteger(TOTAL_RECORDS_FIELD)).isEqualTo(expected);
return items;
}

private TenantItemPairCollection constructTenantItemPairCollection(Map<String, String> tenantsToItemIds) {
return new TenantItemPairCollection()
.withTenantItemPairs(tenantsToItemIds.entrySet().stream()
.map(pair -> new TenantItemPair().withTenantId(pair.getKey()).withItemId(pair.getValue()))
.toList());
}

}
Loading

0 comments on commit 6d09dae

Please sign in to comment.