From 1a65a7447f38c97455fed291b2ca2b5519fc5168 Mon Sep 17 00:00:00 2001 From: Pavlo Smahin Date: Fri, 15 Mar 2024 13:48:23 +0200 Subject: [PATCH] feat(search-instances): implement endpoint for consolidate items access in consortium (#539) Closes: MSEARCH-693 --- NEWS.md | 4 +- README.md | 1 + descriptors/ModuleDescriptor-template.json | 17 ++ .../SearchConsortiumController.java | 19 ++ .../service/ConsortiumSearchContext.java | 17 +- .../search/model/types/ResourceType.java | 1 + .../ConsortiumInstanceRepository.java | 14 ++ .../consortium/ConsortiumInstanceService.java | 7 + .../ConsortiumSearchQueryBuilder.java | 64 +++-- .../resources/swagger.api/mod-search.yaml | 79 +++++++ .../resources/swagger.api/schemas/item.json | 4 + .../controller/SearchItemsConsortiumIT.java | 111 +++++++++ .../ConsortiumSearchQueryBuilderTest.java | 50 +++- .../search/support/base/ApiEndpoints.java | 19 +- .../instance-basic-response.json | 22 +- .../instance-full-response.json | 222 ++++++++++-------- .../samples/semantic-web-primer/items.json | 36 +++ 17 files changed, 547 insertions(+), 140 deletions(-) create mode 100644 src/test/java/org/folio/search/controller/SearchItemsConsortiumIT.java diff --git a/NEWS.md b/NEWS.md index 9f115a4d0..965fe75e7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -21,9 +21,9 @@ * Synchronize browse config with classification types changes ([MSEARCH-683](https://issues.folio.org/browse/MSEARCH-683)) * Authority search: Modify query search option to search authorities by normalized LCCN ([MSEARCH-663](https://issues.folio.org/browse/MSEARCH-663)) * Add ability to case-insensitive search ISSNs with trailing roman numerals ([MSEARCH-672](https://folio-org.atlassian.net/browse/MSEARCH-672)) -* implement endpoint for consolidate holdings access in consortium ([MSEARCH-692](https://folio-org.atlassian.net/browse/MSEARCH-692)) -* Add ability to case insensitive search ISSNs with trailing roman numerals ([MSEARCH-672](https://folio-org.atlassian.net/browse/MSEARCH-672)) * Remove call number format validation for shelving order generation ([MSEARCH-689](https://folio-org.atlassian.net/browse/MSEARCH-689)) +* Implement endpoint for consolidate holdings access in consortium ([MSEARCH-692](https://folio-org.atlassian.net/browse/MSEARCH-692)) +* Implement endpoint for consolidate items access in consortium ([MSEARCH-693](https://folio-org.atlassian.net/browse/MSEARCH-693)) ### Bug fixes * Fix secure setup of system users by default ([MSEARCH-608](https://issues.folio.org/browse/MSEARCH-608)) diff --git a/README.md b/README.md index 4da21d5fa..2aab342c6 100644 --- a/README.md +++ b/README.md @@ -854,6 +854,7 @@ Special API that provide consolidated access to records in consortium environmen | METHOD | URL | DESCRIPTION | |:-------|:------------------------------|:------------------------------| | GET | `/search/consortium/holdings` | Returns consolidated holdings | +| GET | `/search/consortium/items` | Returns consolidated items | ## Additional Information diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index fcf1e1d4c..94fe9a47c 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -154,6 +154,18 @@ "modulePermissions": [ "user-tenants.collection.get" ] + }, + { + "methods": [ + "GET" + ], + "pathPattern": "/search/consortium/items", + "permissionsRequired": [ + "consortium-search.items.collection.get" + ], + "modulePermissions": [ + "user-tenants.collection.get" + ] } ] }, @@ -612,6 +624,11 @@ "permissionName": "consortium-search.holdings.collection.get", "displayName": "Consortium Search - fetch holdings records", "description": "Returns holdings records in consortium" + }, + { + "permissionName": "consortium-search.items.collection.get", + "displayName": "Consortium Search - fetch items records", + "description": "Returns items records in consortium" } ], "launchDescriptor": { diff --git a/src/main/java/org/folio/search/controller/SearchConsortiumController.java b/src/main/java/org/folio/search/controller/SearchConsortiumController.java index e5813ab7c..69f77425e 100644 --- a/src/main/java/org/folio/search/controller/SearchConsortiumController.java +++ b/src/main/java/org/folio/search/controller/SearchConsortiumController.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.folio.search.domain.dto.ConsortiumHoldingCollection; +import org.folio.search.domain.dto.ConsortiumItemCollection; import org.folio.search.domain.dto.SortOrder; import org.folio.search.exception.RequestValidationException; import org.folio.search.model.service.ConsortiumSearchContext; @@ -44,6 +45,24 @@ public ResponseEntity getConsortiumHoldings(String return ResponseEntity.ok(instanceService.fetchHoldings(context)); } + @Override + public ResponseEntity getConsortiumItems(String tenantHeader, String instanceId, + String holdingsRecordId, String tenantId, + Integer limit, Integer offset, String sortBy, + SortOrder sortOrder) { + checkAllowance(tenantHeader); + var context = ConsortiumSearchContext.builderFor(ResourceType.ITEM) + .filter("instanceId", instanceId) + .filter("tenantId", tenantId) + .filter("holdingsRecordId", holdingsRecordId) + .limit(limit) + .offset(offset) + .sortBy(sortBy) + .sortOrder(sortOrder) + .build(); + return ResponseEntity.ok(instanceService.fetchItems(context)); + } + private void checkAllowance(String tenantHeader) { var centralTenant = consortiumTenantService.getCentralTenant(tenantHeader); if (centralTenant.isEmpty() || !centralTenant.get().equals(tenantHeader)) { diff --git a/src/main/java/org/folio/search/model/service/ConsortiumSearchContext.java b/src/main/java/org/folio/search/model/service/ConsortiumSearchContext.java index 332df9f52..5ea0ed21b 100644 --- a/src/main/java/org/folio/search/model/service/ConsortiumSearchContext.java +++ b/src/main/java/org/folio/search/model/service/ConsortiumSearchContext.java @@ -15,14 +15,12 @@ public class ConsortiumSearchContext { static final String SORT_NOT_ALLOWED_MSG = "Not allowed sort field for %s"; static final String FILTER_REQUIRED_MSG = "At least one filter criteria required"; + static final String INSTANCE_ID_FILTER_REQUIRED_MSG = "instanceId filter is required"; private static final Map> ALLOWED_SORT_FIELDS = Map.of( ResourceType.HOLDINGS, List.of("id", "hrid", "tenantId", "instanceId", - "callNumberPrefix", "callNumber", "copyNumber", "permanentLocationId") - ); - - private static final Map DEFAULT_SORT_FIELD = Map.of( - ResourceType.HOLDINGS, "id" + "callNumberPrefix", "callNumber", "copyNumber", "permanentLocationId"), + ResourceType.ITEM, List.of("id", "hrid", "tenantId", "instanceId", "holdingsRecordId", "barcode") ); private final ResourceType resourceType; @@ -36,6 +34,13 @@ public class ConsortiumSearchContext { String sortBy, SortOrder sortOrder) { this.resourceType = resourceType; this.filters = filters; + + if (ResourceType.ITEM == resourceType) { + boolean instanceIdFilterExist = filters.stream().anyMatch(filter -> filter.getFirst().equals("instanceId")); + if (!instanceIdFilterExist) { + throw new RequestValidationException(INSTANCE_ID_FILTER_REQUIRED_MSG, null, null); + } + } if (sortBy != null && !ALLOWED_SORT_FIELDS.get(resourceType).contains(sortBy)) { throw new RequestValidationException(SORT_NOT_ALLOWED_MSG.formatted(resourceType.getValue()), "sortBy", sortBy); } @@ -54,7 +59,7 @@ public static ConsortiumSearchContextBuilder builderFor(ResourceType resourceTyp public static class ConsortiumSearchContextBuilder { private final ResourceType resourceType; - private List> filters = new ArrayList<>(); + private final List> filters = new ArrayList<>(); private Integer limit; private Integer offset; private String sortBy; diff --git a/src/main/java/org/folio/search/model/types/ResourceType.java b/src/main/java/org/folio/search/model/types/ResourceType.java index 2dc260693..6fcc881bc 100644 --- a/src/main/java/org/folio/search/model/types/ResourceType.java +++ b/src/main/java/org/folio/search/model/types/ResourceType.java @@ -7,6 +7,7 @@ public enum ResourceType { INSTANCE("instance"), HOLDINGS("holdings"), + ITEM("item"), AUTHORITY("authority"), CLASSIFICATION_TYPE("classification-type"); diff --git a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java index d17a0b8ac..19acc34a9 100644 --- a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java +++ b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.folio.search.domain.dto.ConsortiumHolding; +import org.folio.search.domain.dto.ConsortiumItem; import org.folio.search.model.types.ResourceType; import org.folio.spring.FolioExecutionContext; import org.springframework.jdbc.core.JdbcTemplate; @@ -92,6 +93,19 @@ public List fetchHoldings(ConsortiumSearchQueryBuilder search ); } + public List fetchItems(ConsortiumSearchQueryBuilder searchQueryBuilder) { + return jdbcTemplate.query(searchQueryBuilder.buildSelectQuery(context), + (rs, rowNum) -> new ConsortiumItem() + .id(rs.getString("id")) + .hrid(rs.getString("hrid")) + .tenantId(rs.getString("tenantId")) + .instanceId(rs.getString("instanceId")) + .holdingsRecordId(rs.getString("holdingsRecordId")) + .barcode(rs.getString("barcode")), + searchQueryBuilder.getQueryArguments() + ); + } + private ConsortiumInstance toConsortiumInstance(ResultSet rs) throws SQLException { var id = new ConsortiumInstanceId(rs.getString(TENANT_ID_COLUMN), rs.getString(INSTANCE_ID_COLUMN)); return new ConsortiumInstance(id, rs.getString(JSON_COLUMN)); diff --git a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceService.java b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceService.java index fa65f9480..a10ff22d2 100644 --- a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceService.java +++ b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceService.java @@ -16,6 +16,8 @@ import org.apache.commons.collections.ListUtils; import org.folio.search.domain.dto.ConsortiumHolding; import org.folio.search.domain.dto.ConsortiumHoldingCollection; +import org.folio.search.domain.dto.ConsortiumItem; +import org.folio.search.domain.dto.ConsortiumItemCollection; import org.folio.search.domain.dto.ResourceEvent; import org.folio.search.domain.dto.ResourceEventType; import org.folio.search.model.event.ConsortiumInstanceEvent; @@ -155,6 +157,11 @@ public ConsortiumHoldingCollection fetchHoldings(ConsortiumSearchContext context return new ConsortiumHoldingCollection().holdings(holdingList).totalRecords(holdingList.size()); } + public ConsortiumItemCollection fetchItems(ConsortiumSearchContext context) { + List itemList = repository.fetchItems(new ConsortiumSearchQueryBuilder(context)); + return new ConsortiumItemCollection().items(itemList).totalRecords(itemList.size()); + } + @SuppressWarnings("unchecked") private void addListItems(List> mergedList, Map instanceMap, String key) { var items = instanceMap.get(key); diff --git a/src/main/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilder.java b/src/main/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilder.java index e2897cd04..ec3110bee 100644 --- a/src/main/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilder.java +++ b/src/main/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilder.java @@ -1,14 +1,18 @@ package org.folio.search.service.consortium; +import static java.util.Collections.emptyList; import static org.apache.commons.lang3.StringUtils.EMPTY; import static org.apache.commons.lang3.StringUtils.SPACE; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.wrap; import static org.folio.search.utils.JdbcUtils.getFullTableName; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.folio.search.model.Pair; import org.folio.search.model.service.ConsortiumSearchContext; @@ -20,32 +24,51 @@ public class ConsortiumSearchQueryBuilder { static final String CONSORTIUM_INSTANCE_TABLE_NAME = "consortium_instance"; public static final Map CONSORTIUM_TABLES = Map.of( ResourceType.INSTANCE, CONSORTIUM_INSTANCE_TABLE_NAME, - ResourceType.HOLDINGS, CONSORTIUM_INSTANCE_TABLE_NAME + ResourceType.HOLDINGS, CONSORTIUM_INSTANCE_TABLE_NAME, + ResourceType.ITEM, CONSORTIUM_INSTANCE_TABLE_NAME ); private static final Map> RESOURCE_FIELDS = Map.of( ResourceType.HOLDINGS, - List.of("id", "hrid", "callNumberPrefix", "callNumber", "copyNumber", "permanentLocationId", "discoverySuppress") + List.of("id", "hrid", "callNumberPrefix", "callNumber", "copyNumber", "permanentLocationId", "discoverySuppress"), + ResourceType.ITEM, + List.of("id", "hrid", "holdingsRecordId", "barcode") ); private static final Map> RESOURCE_FILTER_DATABASE_NAME = Map.of( - ResourceType.HOLDINGS, Map.of("instanceId", "instance_id", "tenantId", "tenant_id") + ResourceType.HOLDINGS, Map.of("instanceId", "instance_id", "tenantId", "tenant_id"), + ResourceType.ITEM, Map.of("instanceId", "instance_id", "tenantId", "tenant_id") + ); + + private static final Map> RESOURCE_JSONB_FILTERS = Map.of( + ResourceType.ITEM, List.of("holdingsRecordId") + ); + + private static final Map RESOURCE_COLLECTION_NAME = Map.of( + ResourceType.HOLDINGS, "holdings", + ResourceType.ITEM, "items" ); private final ConsortiumSearchContext searchContext; + private final ResourceType resourceType; private final List> filters; + private final List> jsonbFilters; public ConsortiumSearchQueryBuilder(ConsortiumSearchContext searchContext) { this.searchContext = searchContext; - this.filters = prepareFilters(searchContext.getResourceType()); + this.resourceType = searchContext.getResourceType(); + this.filters = prepareFilters(resourceType, emptyList(), RESOURCE_JSONB_FILTERS.get(resourceType)); + this.jsonbFilters = prepareFilters(resourceType, RESOURCE_JSONB_FILTERS.get(resourceType), + RESOURCE_FILTER_DATABASE_NAME.get(resourceType).values()); } public String buildSelectQuery(FolioExecutionContext context) { - var resourceType = searchContext.getResourceType(); var fullTableName = getFullTableName(context, CONSORTIUM_TABLES.get(resourceType)); - String subQuery = "SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings FROM " - + fullTableName + SPACE + getWhereClause(filters); + var resourceCollection = RESOURCE_COLLECTION_NAME.get(resourceType); + String subQuery = "SELECT instance_id, tenant_id, json_array_elements(json -> '" + resourceCollection + "') " + + "as " + resourceCollection + " FROM " + fullTableName + SPACE + getWhereClause(filters, null); String query = "SELECT i.instance_id as instanceId, i.tenant_id as tenantId," - + getSelectors("i.holdings", RESOURCE_FIELDS.get(resourceType)) + + getSelectors("i." + resourceCollection, RESOURCE_FIELDS.get(resourceType)) + " FROM (" + subQuery + ") i" + + getWhereClause(jsonbFilters, "i." + resourceCollection) + getOrderByClause() + getLimitClause() + getOffsetClause(); @@ -53,7 +76,7 @@ public String buildSelectQuery(FolioExecutionContext context) { } public Object[] getQueryArguments() { - return filters.stream() + return Stream.concat(filters.stream(), jsonbFilters.stream()) .map(Pair::getSecond) .toArray(); } @@ -95,17 +118,20 @@ private String getSelectors(String source, List sourceFields) { .collect(Collectors.joining(", ")), ' '); } - private String getWhereClause(List> filters) { + private String getWhereClause(List> filters, String source) { if (filters.isEmpty()) { return EMPTY; } var conditionsClause = filters.stream() - .map(filter -> filter.getFirst() + " = ?") + .map(filter -> (StringUtils.isNotBlank(source) + ? getJsonSelector(source, filter.getFirst()) + : filter.getFirst()) + " = ?") .collect(Collectors.joining(" AND ")); - return conditionsClause.isBlank() ? conditionsClause : "WHERE " + conditionsClause; + return conditionsClause.isBlank() ? conditionsClause : wrapped("WHERE " + conditionsClause); } - private List> prepareFilters(ResourceType resourceType) { + private List> prepareFilters(ResourceType resourceType, + List includeFilters, Collection excludeFilters) { var mappedFilterNames = RESOURCE_FILTER_DATABASE_NAME.get(resourceType); return searchContext.getFilters().stream() .map(filter -> { @@ -113,7 +139,17 @@ private List> prepareFilters(ResourceType resourceType) { return Pair.pair(mappedFilterNames.get(filter.getFirst()), filter.getSecond()); } return filter; - }).toList(); + }) + .filter(filter -> { + if (CollectionUtils.isNotEmpty(includeFilters)) { + return includeFilters.contains(filter.getFirst()); + } + if (CollectionUtils.isNotEmpty(excludeFilters)) { + return !excludeFilters.contains(filter.getFirst()); + } + return true; + }) + .toList(); } } diff --git a/src/main/resources/swagger.api/mod-search.yaml b/src/main/resources/swagger.api/mod-search.yaml index 2a33c0487..cbb867080 100644 --- a/src/main/resources/swagger.api/mod-search.yaml +++ b/src/main/resources/swagger.api/mod-search.yaml @@ -228,6 +228,33 @@ paths: '500': $ref: '#/components/responses/internalServerErrorResponse' + /search/consortium/items: + get: + operationId: getConsortiumItems + description: Get a list of items (only for consortium environment) + tags: + - search-consortium + parameters: + - $ref: '#/components/parameters/instance-id-query-param' + - $ref: '#/components/parameters/holdings-id-query-param' + - $ref: '#/components/parameters/tenant-id-query-param' + - $ref: '#/components/parameters/consortium-limit-param' + - $ref: '#/components/parameters/offset-param' + - $ref: '#/components/parameters/sort-by-item-param' + - $ref: '#/components/parameters/sort-order-param' + - $ref: '#/components/parameters/x-okapi-tenant-header' + responses: + '200': + description: List of items + content: + application/json: + schema: + $ref: '#/components/schemas/consortiumItemCollection' + '400': + $ref: '#/components/responses/badRequestResponse' + '500': + $ref: '#/components/responses/internalServerErrorResponse' + /browse/call-numbers/instances: get: operationId: browseInstancesByCallNumber @@ -790,6 +817,36 @@ components: $ref: '#/components/schemas/consortiumHolding' totalRecords: type: integer + consortiumItem: + type: object + properties: + id: + description: Item ID + type: string + hrid: + description: Item HRID + type: string + tenantId: + description: Tenant ID of the Item + type: string + instanceId: + description: Related Instance Id + type: string + holdingsRecordId: + description: Related Holding Record Id + type: string + barcode: + description: Item barcode + type: string + consortiumItemCollection: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/consortiumItem' + totalRecords: + type: integer sortOrder: type: string enum: @@ -941,6 +998,13 @@ components: required: false schema: type: string + holdings-id-query-param: + in: query + name: holdingsRecordId + description: UUID of the holdings record + required: false + schema: + type: string tenant-id-query-param: in: query name: tenantId @@ -965,6 +1029,21 @@ components: required: false schema: type: string + sort-by-item-param: + in: query + name: sortBy + description: | + Defines a field to sort by. + Possible values: + - id + - hrid + - tenantId + - instanceId + - holdingsRecordId + - barcode + required: false + schema: + type: string sort-order-param: in: query name: sortOrder diff --git a/src/main/resources/swagger.api/schemas/item.json b/src/main/resources/swagger.api/schemas/item.json index 5b5200794..f7de452f5 100644 --- a/src/main/resources/swagger.api/schemas/item.json +++ b/src/main/resources/swagger.api/schemas/item.json @@ -11,6 +11,10 @@ "description": "Tenant ID", "type": "string" }, + "holdingsRecordId": { + "description": "Holdings record ID", + "type": "string" + }, "hrid": { "type": "string", "description": "The human readable ID, also called eye readable ID. A system-assigned sequential alternate ID" diff --git a/src/test/java/org/folio/search/controller/SearchItemsConsortiumIT.java b/src/test/java/org/folio/search/controller/SearchItemsConsortiumIT.java new file mode 100644 index 000000000..a315e5024 --- /dev/null +++ b/src/test/java/org/folio/search/controller/SearchItemsConsortiumIT.java @@ -0,0 +1,111 @@ +package org.folio.search.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.folio.search.controller.SearchConsortiumController.REQUEST_NOT_ALLOWED_MSG; +import static org.folio.search.model.Pair.pair; +import static org.folio.search.sample.SampleInstances.getSemanticWeb; +import static org.folio.search.sample.SampleInstances.getSemanticWebId; +import static org.folio.search.support.base.ApiEndpoints.consortiumItemsSearchPath; +import static org.folio.search.utils.TestConstants.CENTRAL_TENANT_ID; +import static org.folio.search.utils.TestConstants.MEMBER_TENANT_ID; +import static org.folio.search.utils.TestUtils.parseResponse; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.folio.search.domain.dto.ConsortiumItem; +import org.folio.search.domain.dto.ConsortiumItemCollection; +import org.folio.search.model.Pair; +import org.folio.search.support.base.BaseConsortiumIntegrationTest; +import org.folio.spring.testing.type.IntegrationTest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +@IntegrationTest +class SearchItemsConsortiumIT extends BaseConsortiumIntegrationTest { + + @BeforeAll + static void prepare() { + setUpTenant(CENTRAL_TENANT_ID); + setUpTenant(MEMBER_TENANT_ID, getSemanticWeb()); + } + + @AfterAll + static void cleanUp() { + removeTenant(); + } + + @Test + void doGetConsortiumItems_returns200AndRecords() { + List> queryParams = List.of( + pair("instanceId", getSemanticWebId()) + ); + var result = doGet(consortiumItemsSearchPath(queryParams), CENTRAL_TENANT_ID); + var actual = parseResponse(result, ConsortiumItemCollection.class); + + assertThat(actual.getTotalRecords()).isEqualTo(3); + assertThat(actual.getItems()).containsExactlyInAnyOrder(getExpectedItems()); + } + + @Test + void doGetConsortiumItems_returns200AndRecords_withAllQueryParams() { + List> queryParams = List.of( + pair("instanceId", getSemanticWebId()), + pair("tenantId", MEMBER_TENANT_ID), + pair("holdingsRecordId", "e3ff6133-b9a2-4d4c-a1c9-dc1867d4df19"), + pair("limit", "1"), + pair("offset", "1"), + pair("sortBy", "barcode"), + pair("sortOrder", "desc") + ); + var result = doGet(consortiumItemsSearchPath(queryParams), CENTRAL_TENANT_ID); + var actual = parseResponse(result, ConsortiumItemCollection.class); + + assertThat(actual.getTotalRecords()).isEqualTo(1); + assertThat(actual.getItems()) + .satisfiesExactly(input -> assertEquals("10101", input.getBarcode())); + } + + @Test + void tryGetConsortiumItems_returns400_whenRequestedForNotCentralTenant() throws Exception { + tryGet(consortiumItemsSearchPath()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors[0].message", is(REQUEST_NOT_ALLOWED_MSG))) + .andExpect(jsonPath("$.errors[0].type", is("RequestValidationException"))) + .andExpect(jsonPath("$.errors[0].code", is("validation_error"))) + .andExpect(jsonPath("$.errors[0].parameters[0].key", is("x-okapi-tenant"))) + .andExpect(jsonPath("$.errors[0].parameters[0].value", is(MEMBER_TENANT_ID))); + } + + @Test + void tryGetConsortiumItems_returns400_whenInstanceIdIsNotSpecified() throws Exception { + List> queryParams = List.of( + pair("limit", "1"), + pair("offset", "1"), + pair("sortBy", "barcode"), + pair("sortOrder", "desc") + ); + tryGet(consortiumItemsSearchPath(queryParams), CENTRAL_TENANT_ID) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors[0].message", is("instanceId filter is required"))) + .andExpect(jsonPath("$.errors[0].type", is("RequestValidationException"))) + .andExpect(jsonPath("$.errors[0].code", is("validation_error"))); + } + + private ConsortiumItem[] getExpectedItems() { + var instance = getSemanticWeb(); + return instance.getItems().stream() + .map(item -> new ConsortiumItem() + .id(item.getId()) + .hrid(item.getHrid()) + .tenantId(MEMBER_TENANT_ID) + .instanceId(instance.getId()) + .holdingsRecordId(item.getHoldingsRecordId()) + .barcode(item.getBarcode()) + ) + .toArray(ConsortiumItem[]::new); + } +} diff --git a/src/test/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilderTest.java b/src/test/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilderTest.java index 82f41bd64..8c7c96ff6 100644 --- a/src/test/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilderTest.java +++ b/src/test/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilderTest.java @@ -54,7 +54,7 @@ void testBuildSelectQuery_forHoldingsResource_whenAllParametersDefined() { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "ORDER BY id desc LIMIT 100 OFFSET 10", actual); } @@ -87,7 +87,7 @@ void testBuildSelectQuery_forHoldingsResource_whenSortByEmpty(String sortBy) { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "LIMIT 100 OFFSET 10", actual); } @@ -102,7 +102,7 @@ void testBuildSelectQuery_forHoldingsResource_whenSortOrderEmpty() { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "ORDER BY id LIMIT 100 OFFSET 10", actual); } @@ -117,7 +117,7 @@ void testBuildSelectQuery_forHoldingsResource_whenLimitEmpty() { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "ORDER BY id desc OFFSET 10", actual); } @@ -132,14 +132,41 @@ void testBuildSelectQuery_forHoldingsResource_whenOffsetEmpty() { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "ORDER BY id desc LIMIT 100", actual); } + @Test + void testBuildSelectQuery_forItemResource_whenAllParametersDefined() { + var searchContext = new SearchContextMockBuilder().forItem().build(); + + var actual = new ConsortiumSearchQueryBuilder(searchContext).buildSelectQuery(executionContext); + assertEquals("SELECT i.instance_id as instanceId, i.tenant_id as tenantId, " + + "i.items ->> 'id' AS id, i.items ->> 'hrid' AS hrid, " + + "i.items ->> 'holdingsRecordId' AS holdingsRecordId, i.items ->> 'barcode' AS barcode " + + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'items') as items " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + + "WHERE i.items ->> 'holdingsRecordId' = ? ORDER BY id desc LIMIT 100 OFFSET 10", actual); + } + + @Test + void testBuildSelectQuery_forItemResource_whenJsonbFilterIsEmpty() { + var searchContext = new SearchContextMockBuilder().forItem().withHoldingsRecordId(null).build(); + + var actual = new ConsortiumSearchQueryBuilder(searchContext).buildSelectQuery(executionContext); + assertEquals("SELECT i.instance_id as instanceId, i.tenant_id as tenantId, " + + "i.items ->> 'id' AS id, i.items ->> 'hrid' AS hrid, " + + "i.items ->> 'holdingsRecordId' AS holdingsRecordId, i.items ->> 'barcode' AS barcode " + + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'items') as items " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + + "ORDER BY id desc LIMIT 100 OFFSET 10", actual); + } + private static final class SearchContextMockBuilder { private ResourceType resourceType; private String instanceId = "inst123"; private String tenantId = "tenant"; + private String holdingsRecordId = "holding123"; private String sortBy = "id"; private SortOrder sortOrder = SortOrder.DESC; private Integer limit = 100; @@ -150,11 +177,21 @@ SearchContextMockBuilder forHoldings() { return this; } + SearchContextMockBuilder forItem() { + this.resourceType = ResourceType.ITEM; + return this; + } + SearchContextMockBuilder withInstanceId(String instanceId) { this.instanceId = instanceId; return this; } + SearchContextMockBuilder withHoldingsRecordId(String holdingsRecordId) { + this.holdingsRecordId = holdingsRecordId; + return this; + } + SearchContextMockBuilder withTenantId(String tenantId) { this.tenantId = tenantId; return this; @@ -200,6 +237,9 @@ private ArrayList> getFilters() { if (StringUtils.isNotBlank(tenantId)) { filters.add(Pair.pair("tenantId", tenantId)); } + if (StringUtils.isNotBlank(holdingsRecordId) && resourceType == ResourceType.ITEM) { + filters.add(Pair.pair("holdingsRecordId", tenantId)); + } return filters; } } diff --git a/src/test/java/org/folio/search/support/base/ApiEndpoints.java b/src/test/java/org/folio/search/support/base/ApiEndpoints.java index e9136460b..b3206ed62 100644 --- a/src/test/java/org/folio/search/support/base/ApiEndpoints.java +++ b/src/test/java/org/folio/search/support/base/ApiEndpoints.java @@ -22,12 +22,16 @@ public static String consortiumHoldingsSearchPath() { } public static String consortiumHoldingsSearchPath(List> queryParams) { - var queryParamString = queryParams.stream() - .map(param -> param.getFirst() + "=" + param.getSecond()) - .collect(Collectors.joining("&")); - return consortiumHoldingsSearchPath() + "?" + queryParamString; + return addQueryParams(consortiumHoldingsSearchPath(), queryParams); } + public static String consortiumItemsSearchPath() { + return "/search/consortium/items"; + } + + public static String consortiumItemsSearchPath(List> queryParams) { + return addQueryParams(consortiumItemsSearchPath(), queryParams); + } public static String authoritySearchPath() { return "/search/authorities"; @@ -117,4 +121,11 @@ public static String updateIndexSettingsPath() { public static String allRecordsSortedBy(String sort, CqlSort order) { return String.format("cql.allRecords=1 sortBy %s/sort.%s", sort, order); } + + private static String addQueryParams(String path, List> queryParams) { + var queryParamString = queryParams.stream() + .map(param -> param.getFirst() + "=" + param.getSecond()) + .collect(Collectors.joining("&")); + return path + "?" + queryParamString; + } } diff --git a/src/test/resources/samples/instance-response-sample/instance-basic-response.json b/src/test/resources/samples/instance-response-sample/instance-basic-response.json index f63a7e3cb..b95856f13 100644 --- a/src/test/resources/samples/instance-response-sample/instance-basic-response.json +++ b/src/test/resources/samples/instance-response-sample/instance-basic-response.json @@ -33,8 +33,6 @@ "notes": [], "items": [ { - "chronology": "", - "copyNumber": "Copy 2", "effectiveCallNumberComponents": { "callNumber": "TK5105.88815 . A58 2004 FT MEADE", "prefix": "prefix-10101", @@ -42,13 +40,9 @@ "typeId": "512173a7-bd09-490e-b773-17d83f2b63fe" }, "effectiveShelvingOrder": "TK 45105.88815 A58 42004 FT MEADE", - "enumeration": "", - "notes": [], - "volume": "v1", - "yearCaption": [] + "notes": [] }, { - "chronology": "", "effectiveCallNumberComponents": { "callNumber": "TK5105.88815 . A58 2004 FT MEADE", "prefix": "prefix-90000", @@ -56,9 +50,17 @@ "typeId": "512173a7-bd09-490e-b773-17d83f2b63fe" }, "effectiveShelvingOrder": "TK 45105.88815 A58 42004 FT MEADE", - "enumeration": "", - "notes": [], - "yearCaption": [] + "notes": [] + }, + { + "effectiveCallNumberComponents": { + "callNumber": "332. 2", + "prefix": "prefix 2", + "suffix": "suff 2", + "typeId": "03dd64d0-5626-4ecd-8ece-4531e0069f35" + }, + "effectiveShelvingOrder": "3332 12 suff 2", + "notes": [] } ], "holdings": [] diff --git a/src/test/resources/samples/instance-response-sample/instance-full-response.json b/src/test/resources/samples/instance-response-sample/instance-full-response.json index 6a59c9786..4fa4e064b 100644 --- a/src/test/resources/samples/instance-response-sample/instance-full-response.json +++ b/src/test/resources/samples/instance-response-sample/instance-full-response.json @@ -171,20 +171,20 @@ ], "items": [ { + "id": "7212ba6a-8dcf-45a1-be9a-ffaa847c4423", + "hrid": "item000000000014", "accessionNumber": "item_accession_number", - "administrativeNotes": [ - "v1.1", - "need attention" + "formerIds": [ + "207b9372-127f-4bdd-83e0-147c9fe9bc16", + "81ae0f60-f2bc-450c-84c8-5a21096daed9" ], + "itemIdentifier": "itemIdentifierFieldValue", "barcode": "10101", - "chronology": "", - "circulationNotes": [ - { - "note": "testCirculationNote", - "staffOnly": false - } - ], - "copyNumber": "Copy 2", + "effectiveLocationId": "fcd64ce1-6995-48f0-840e-89ffa2288371", + "status": { + "name": "Available" + }, + "materialTypeId": "1a54b431-2e4f-452d-9cae-9cee66c9a892", "discoverySuppress": false, "effectiveCallNumberComponents": { "callNumber": "TK5105.88815 . A58 2004 FT MEADE", @@ -192,8 +192,13 @@ "suffix": "suffix-10101", "typeId": "512173a7-bd09-490e-b773-17d83f2b63fe" }, - "effectiveLocationId": "fcd64ce1-6995-48f0-840e-89ffa2288371", "effectiveShelvingOrder": "TK 45105.88815 A58 42004 FT MEADE", + "itemLevelCallNumberTypeId": "5ba6b62e-6858-490a-8102-5b1369873835", + "tags": { + "tagList": [ + "item-tag" + ] + }, "electronicAccess": [ { "uri": "https://www.loc.gov/catdir/toc/ecip0718/2007020429.html", @@ -201,52 +206,36 @@ "publicNote": "Table of contents only" } ], - "enumeration": "", - "formerIds": [ - "207b9372-127f-4bdd-83e0-147c9fe9bc16", - "81ae0f60-f2bc-450c-84c8-5a21096daed9" + "administrativeNotes": [ + "v1.1", + "need attention" ], - "hrid": "item000000000014", - "id": "7212ba6a-8dcf-45a1-be9a-ffaa847c4423", - "itemIdentifier": "itemIdentifierFieldValue", - "itemLevelCallNumberTypeId": "5ba6b62e-6858-490a-8102-5b1369873835", - "materialTypeId": "1a54b431-2e4f-452d-9cae-9cee66c9a892", - "metadata": { - "createdDate": "2020-12-08T15:47:21.327+00:00", - "updatedDate": "2021-01-18T08:17:56.752+00:00", - "updatedByUserId": "d2533d6c-a42c-5bff-ad77-22f87c297925" - }, "notes": [], "statisticalCodeIds": [ "b5968c9e-cddc-4576-99e3-8e60aed8b0dd" ], - "status": { - "name": "Available" - }, - "tags": { - "tagList": [ - "item-tag" - ] - }, - "volume": "v1", - "yearCaption": [] - }, - { - "administrativeNotes": [ - "v1.2" - ], - "barcode": "90000", - "chronology": "", "circulationNotes": [ { - "note": "private circulation note", - "staffOnly": true - }, - { - "note": "public circulation note", + "note": "testCirculationNote", "staffOnly": false } ], + "metadata": { + "createdDate": "2020-12-08T15:47:21.327+00:00", + "updatedDate": "2021-01-18T08:17:56.752+00:00", + "updatedByUserId": "d2533d6c-a42c-5bff-ad77-22f87c297925" + } + }, + { + "id": "100d10bf-2f06-4aa0-be15-0b95b2d9f9e3", + "hrid": "item000000000015", + "formerIds": [], + "barcode": "90000", + "effectiveLocationId": "fcd64ce1-6995-48f0-840e-89ffa2288371", + "status": { + "name": "Available" + }, + "materialTypeId": "1a54b431-2e4f-452d-9cae-9cee66c9a892", "discoverySuppress": false, "effectiveCallNumberComponents": { "callNumber": "TK5105.88815 . A58 2004 FT MEADE", @@ -254,7 +243,6 @@ "suffix": "suffix-90000", "typeId": "512173a7-bd09-490e-b773-17d83f2b63fe" }, - "effectiveLocationId": "fcd64ce1-6995-48f0-840e-89ffa2288371", "effectiveShelvingOrder": "TK 45105.88815 A58 42004 FT MEADE", "electronicAccess": [ { @@ -263,15 +251,9 @@ "publicNote": "Table of contents only" } ], - "enumeration": "", - "formerIds": [], - "hrid": "item000000000015", - "id": "100d10bf-2f06-4aa0-be15-0b95b2d9f9e3", - "materialTypeId": "1a54b431-2e4f-452d-9cae-9cee66c9a892", - "metadata": { - "createdDate": "2020-12-08T15:47:22.827+00:00", - "updatedDate": "2020-12-08T15:47:22.827+00:00" - }, + "administrativeNotes": [ + "v1.2" + ], "notes": [ { "note": "Includes bibliographical references and index of item.", @@ -289,39 +271,90 @@ "statisticalCodeIds": [ "615e9911-edb1-4ab3-a9c3-a461a3de02f8" ], + "circulationNotes": [ + { + "note": "private circulation note", + "staffOnly": true + }, + { + "note": "public circulation note", + "staffOnly": false + } + ], + "metadata": { + "createdDate": "2020-12-08T15:47:22.827+00:00", + "updatedDate": "2020-12-08T15:47:22.827+00:00" + } + }, + { + "id": "49347f02-69ac-4c12-b6f2-2afb6059c94b", + "hrid": "it00000000001", + "formerIds": [], + "barcode": "12345", + "effectiveLocationId": "cdd60388-0c75-4969-b3c5-2d04621ed26f", "status": { "name": "Available" }, - "yearCaption": [] + "materialTypeId": "5ee11d91-f7e8-481d-b079-65d708582ccc", + "discoverySuppress": false, + "effectiveCallNumberComponents": { + "callNumber": "332. 2", + "prefix": "prefix 2", + "suffix": "suff 2", + "typeId": "03dd64d0-5626-4ecd-8ece-4531e0069f35" + }, + "effectiveShelvingOrder": "3332 12 suff 2", + "tags": { + "tagList": [] + }, + "electronicAccess": [], + "administrativeNotes": [], + "notes": [], + "statisticalCodeIds": [], + "circulationNotes": [], + "metadata": { + "createdDate": "2024-03-14T13:05:09.406+00:00", + "createdByUserId": "93fc1367-7321-4836-b2a7-cbc7ffe8c20e", + "updatedDate": "2024-03-14T13:05:09.406+00:00", + "updatedByUserId": "93fc1367-7321-4836-b2a7-cbc7ffe8c20e" + } } ], "holdings": [ { - "administrativeNotes": [ - "v2.0", - "for deletion" - ], - "callNumber": null, + "id": "e3ff6133-b9a2-4d4c-a1c9-dc1867d4df19", + "permanentLocationId": "fcd64ce1-6995-48f0-840e-89ffa2288371", "discoverySuppress": true, - "electronicAccess": [], + "hrid": "hold000000000009", "formerIds": [ "1d76ee84-d776-48d2-ab96-140c24e39ac5" ], + "statisticalCodeIds": [ + "a2b01891-c9ab-4d04-8af8-8989af1c6aad" + ], "holdingsTypeId": "03c9c400-b9e3-4a07-ac0e-05ab470233ed", - "hrid": "hold000000000009", - "id": "e3ff6133-b9a2-4d4c-a1c9-dc1867d4df19", + "electronicAccess": [], + "administrativeNotes": [ + "v2.0", + "for deletion" + ], + "notes": [], "metadata": { "createdDate": "2020-12-08T15:47:19.330+00:00", "updatedDate": "2020-12-08T15:47:19.330+00:00" - }, - "notes": [], - "permanentLocationId": "fcd64ce1-6995-48f0-840e-89ffa2288371", - "statisticalCodeIds": [ - "a2b01891-c9ab-4d04-8af8-8989af1c6aad" - ] + } }, { + "id": "9550c935-401a-4a85-875e-4d1fe7678870", + "permanentLocationId": "fcd64ce1-6995-48f0-840e-89ffa2288371", "discoverySuppress": false, + "hrid": "ho00000000006", + "sourceId": "f32d531e-df79-46b3-8932-cdd35f7a2264", + "formerIds": [ + "ac727d8c-f422-4c3f-815e-e85694095e71", + "9b8ec096-fa2e-451b-8e7a-6d1c977ee946" + ], + "statisticalCodeIds": [], "electronicAccess": [ { "uri": "https://testlibrary.sample.com/holdings?hrid=ho0000006", @@ -329,18 +362,6 @@ "publicNote": "Holding's electronicAccess public note" } ], - "formerIds": [ - "ac727d8c-f422-4c3f-815e-e85694095e71", - "9b8ec096-fa2e-451b-8e7a-6d1c977ee946" - ], - "hrid": "ho00000000006", - "id": "9550c935-401a-4a85-875e-4d1fe7678870", - "metadata": { - "createdDate": "2021-03-04T12:36:17.211+00:00", - "createdByUserId": "d2533d6c-a42c-5bff-ad77-22f87c297925", - "updatedDate": "2021-03-04T12:36:17.211+00:00", - "updatedByUserId": "d2533d6c-a42c-5bff-ad77-22f87c297925" - }, "notes": [ { "note": "Includes bibliographical references and index of holdings.", @@ -355,30 +376,33 @@ "staffOnly": true } ], - "permanentLocationId": "fcd64ce1-6995-48f0-840e-89ffa2288371", - "sourceId": "f32d531e-df79-46b3-8932-cdd35f7a2264", - "statisticalCodeIds": [] - }, - { - "discoverySuppress": false, - "electronicAccess": [], - "formerIds": [], - "hrid": "ho00000000007", - "id": "a663dea9-6547-4b2d-9daa-76cadd662272", "metadata": { - "createdDate": "2021-03-04T12:36:57.751+00:00", + "createdDate": "2021-03-04T12:36:17.211+00:00", "createdByUserId": "d2533d6c-a42c-5bff-ad77-22f87c297925", - "updatedDate": "2021-03-04T12:36:57.751+00:00", + "updatedDate": "2021-03-04T12:36:17.211+00:00", "updatedByUserId": "d2533d6c-a42c-5bff-ad77-22f87c297925" - }, - "notes": [], + } + }, + { + "id": "a663dea9-6547-4b2d-9daa-76cadd662272", "permanentLocationId": "758258bc-ecc1-41b8-abca-f7b610822ffd", + "discoverySuppress": false, + "hrid": "ho00000000007", "sourceId": "f32d531e-df79-46b3-8932-cdd35f7a2264", + "formerIds": [], "statisticalCodeIds": [], "tags": { "tagList": [ "holdings-tag" ] + }, + "electronicAccess": [], + "notes": [], + "metadata": { + "createdDate": "2021-03-04T12:36:57.751+00:00", + "createdByUserId": "d2533d6c-a42c-5bff-ad77-22f87c297925", + "updatedDate": "2021-03-04T12:36:57.751+00:00", + "updatedByUserId": "d2533d6c-a42c-5bff-ad77-22f87c297925" } } ] diff --git a/src/test/resources/samples/semantic-web-primer/items.json b/src/test/resources/samples/semantic-web-primer/items.json index 92b3519dc..5ef89aa37 100644 --- a/src/test/resources/samples/semantic-web-primer/items.json +++ b/src/test/resources/samples/semantic-web-primer/items.json @@ -133,5 +133,41 @@ "createdDate": "2020-12-08T15:47:22.827+00:00", "updatedDate": "2020-12-08T15:47:22.827+00:00" } + }, + { + "id": "49347f02-69ac-4c12-b6f2-2afb6059c94b", + "hrid": "it00000000001", + "holdingsRecordId": "9550c935-401a-4a85-875e-4d1fe7678870", + "formerIds": [], + "barcode": "12345", + "effectiveShelvingOrder": "3332 12 suff 2", + "effectiveCallNumberComponents": { + "callNumber": "332. 2", + "prefix": "prefix 2", + "suffix": "suff 2", + "typeId": "03dd64d0-5626-4ecd-8ece-4531e0069f35" + }, + "yearCaption": [], + "administrativeNotes": [], + "notes": [], + "circulationNotes": [], + "status": { + "name": "Available", + "date": "2024-03-14T13:05:09.407+00:00" + }, + "materialTypeId": "5ee11d91-f7e8-481d-b079-65d708582ccc", + "permanentLoanTypeId": "e8b311a6-3b21-43f2-a269-dd9310cb2d0e", + "effectiveLocationId": "cdd60388-0c75-4969-b3c5-2d04621ed26f", + "electronicAccess": [], + "statisticalCodeIds": [], + "tags": { + "tagList": [] + }, + "metadata": { + "createdDate": "2024-03-14T13:05:09.406+00:00", + "createdByUserId": "93fc1367-7321-4836-b2a7-cbc7ffe8c20e", + "updatedDate": "2024-03-14T13:05:09.406+00:00", + "updatedByUserId": "93fc1367-7321-4836-b2a7-cbc7ffe8c20e" + } } ]