Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MODINVSTOR-1243] Implement endpoint to retrieve items from multiple tenants #755

Merged
merged 17 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Aliaksandr-Fedasiuk marked this conversation as resolved.
Show resolved Hide resolved

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))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you say why "tenantId" field is set to the item json? Looks like it does not comply with the response definition because the item schema does not contain "tenantId" field.

Copy link
Contributor

@SerhiiNosko SerhiiNosko Aug 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In screen from testing I see that response object has tenantId populated:
image
Maybe Item object allows to add properties dynamically

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the field "tenantId" successfully has been added to response because in the code we set it to JsonObject representing item and that allows to add any property

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think we can improve this in some way, for example by adding tenantId to Item schema explicitly?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose yes, or maybe to add a new response schema for this endpoint that would refer to the item schema

.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
Loading