diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json
index 7d4581ed..94d6d14e 100644
--- a/descriptors/ModuleDescriptor-template.json
+++ b/descriptors/ModuleDescriptor-template.json
@@ -16,7 +16,9 @@
"methods": ["POST"],
"pathPattern": "/tlr/ecs-tlr",
"permissionsRequired": ["tlr.ecs-tlr.post"],
- "modulePermissions": []
+ "modulePermissions": [
+ "circulation.requests.item.post"
+ ]
}
]
},
@@ -71,9 +73,22 @@
}
},
"env": [
- { "name": "JAVA_OPTIONS",
+ {
+ "name": "JAVA_OPTIONS",
"value": "-XX:MaxRAMPercentage=66.0"
},
+ {
+ "name": "OKAPI_URL",
+ "value": "http://okapi:9130"
+ },
+ {
+ "name": "KAFKA_HOST",
+ "value": "kafka"
+ },
+ {
+ "name": "KAFKA_PORT",
+ "value": "9092"
+ },
{ "name": "DB_HOST", "value": "postgres" },
{ "name": "DB_PORT", "value": "5432" },
{ "name": "DB_USERNAME", "value": "folio_admin" },
diff --git a/pom.xml b/pom.xml
index 2f422aa0..043b812f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -105,6 +105,11 @@
provided
+
+ org.springframework
+ spring-webflux
+
+
org.springframework.boot
diff --git a/src/main/java/org/folio/client/CirculationClient.java b/src/main/java/org/folio/client/CirculationClient.java
new file mode 100644
index 00000000..2c0cb4bc
--- /dev/null
+++ b/src/main/java/org/folio/client/CirculationClient.java
@@ -0,0 +1,13 @@
+package org.folio.client;
+
+import org.folio.domain.dto.Request;
+import org.folio.spring.config.FeignClientConfiguration;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+
+@FeignClient(name = "circulation", url = "${folio.okapi-url}", configuration = FeignClientConfiguration.class)
+public interface CirculationClient {
+
+ @PostMapping("/circulation/requests")
+ Request createRequest(Request request);
+}
diff --git a/src/main/java/org/folio/domain/entity/EcsTlrEntity.java b/src/main/java/org/folio/domain/entity/EcsTlrEntity.java
index 77984b48..84343d04 100644
--- a/src/main/java/org/folio/domain/entity/EcsTlrEntity.java
+++ b/src/main/java/org/folio/domain/entity/EcsTlrEntity.java
@@ -26,6 +26,7 @@ public class EcsTlrEntity {
private String requestType;
private String requestLevel;
private Date requestExpirationDate;
+ private Date requestDate;
private String patronComments;
private String fulfillmentPreference;
private UUID pickupServicePointId;
diff --git a/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java b/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java
index f4d97f9d..96285bb6 100644
--- a/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java
+++ b/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java
@@ -1,6 +1,7 @@
package org.folio.domain.mapper;
import org.folio.domain.dto.EcsTlr;
+import org.folio.domain.dto.Request;
import org.folio.domain.entity.EcsTlrEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@@ -49,4 +50,6 @@ default String mapRequestLevelToString(EcsTlr.RequestLevelEnum requestLevelEnum)
default String mapFulfillmentPreferenceToString(EcsTlr.FulfillmentPreferenceEnum fulfillmentPreferenceEnum) {
return fulfillmentPreferenceEnum != null ? fulfillmentPreferenceEnum.getValue() : null;
}
+
+ Request mapDtoToRequest(EcsTlr ecsTlr);
}
diff --git a/src/main/java/org/folio/exception/TenantScopedExecutionException.java b/src/main/java/org/folio/exception/TenantScopedExecutionException.java
new file mode 100644
index 00000000..dec59750
--- /dev/null
+++ b/src/main/java/org/folio/exception/TenantScopedExecutionException.java
@@ -0,0 +1,13 @@
+package org.folio.exception;
+
+import lombok.Getter;
+
+@Getter
+public class TenantScopedExecutionException extends RuntimeException {
+ private final String tenantId;
+
+ public TenantScopedExecutionException(Exception cause, String tenantId) {
+ super(cause);
+ this.tenantId = tenantId;
+ }
+}
diff --git a/src/main/java/org/folio/service/TenantScopedExecutionService.java b/src/main/java/org/folio/service/TenantScopedExecutionService.java
new file mode 100644
index 00000000..d20bf68c
--- /dev/null
+++ b/src/main/java/org/folio/service/TenantScopedExecutionService.java
@@ -0,0 +1,8 @@
+package org.folio.service;
+
+import java.util.concurrent.Callable;
+
+public interface TenantScopedExecutionService {
+
+ T execute(String tenantId, Callable action);
+}
diff --git a/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java b/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java
index 43d20595..7456516d 100644
--- a/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java
+++ b/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java
@@ -3,9 +3,12 @@
import java.util.Optional;
import java.util.UUID;
+import org.folio.client.CirculationClient;
import org.folio.domain.dto.EcsTlr;
+import org.folio.domain.dto.Request;
import org.folio.domain.mapper.EcsTlrMapper;
import org.folio.repository.EcsTlrRepository;
+import org.folio.service.TenantScopedExecutionService;
import org.folio.service.EcsTlrService;
import org.springframework.stereotype.Service;
@@ -19,6 +22,8 @@ public class EcsTlrServiceImpl implements EcsTlrService {
private final EcsTlrRepository ecsTlrRepository;
private final EcsTlrMapper requestsMapper;
+ private final CirculationClient circulationClient;
+ private final TenantScopedExecutionService tenantScopedExecutionService;
@Override
public Optional get(UUID id) {
@@ -31,8 +36,20 @@ public Optional get(UUID id) {
@Override
public EcsTlr post(EcsTlr ecsTlr) {
log.debug("post:: parameters ecsTlr: {}", () -> ecsTlr);
+ createRemoteRequest(ecsTlr, "university"); // TODO: replace with real tenantId
return requestsMapper.mapEntityToDto(ecsTlrRepository.save(
requestsMapper.mapDtoToEntity(ecsTlr)));
}
+
+ private Request createRemoteRequest(EcsTlr ecsTlr, String tenantId) {
+ log.info("createRemoteRequest:: creating remote request for ECS TLR {} and tenant {}", ecsTlr.getId(), tenantId);
+ Request mappedRequest = requestsMapper.mapDtoToRequest(ecsTlr);
+ Request createdRequest = tenantScopedExecutionService.execute(tenantId,
+ () -> circulationClient.createRequest(mappedRequest));
+ log.info("createRemoteRequest:: request created: {}", createdRequest.getId());
+ log.debug("createRemoteRequest:: request: {}", () -> createdRequest);
+
+ return createdRequest;
+ }
}
diff --git a/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java b/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java
new file mode 100644
index 00000000..5d871d98
--- /dev/null
+++ b/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java
@@ -0,0 +1,40 @@
+package org.folio.service.impl;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+
+import org.folio.exception.TenantScopedExecutionException;
+import org.folio.service.TenantScopedExecutionService;
+import org.folio.spring.FolioExecutionContext;
+import org.folio.spring.FolioModuleMetadata;
+import org.folio.spring.integration.XOkapiHeaders;
+import org.folio.spring.scope.FolioExecutionContextSetter;
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@Service
+@RequiredArgsConstructor
+@Log4j2
+public class TenantScopedExecutionServiceImpl implements TenantScopedExecutionService {
+
+ private final FolioModuleMetadata moduleMetadata;
+ private final FolioExecutionContext executionContext;
+
+ @Override
+ public T execute(String tenantId, Callable action) {
+ log.info("execute:: tenantId: {}", tenantId);
+ Map> headers = executionContext.getAllHeaders();
+ headers.put(XOkapiHeaders.TENANT, List.of(tenantId));
+
+ try (var x = new FolioExecutionContextSetter(moduleMetadata, headers)) {
+ return action.call();
+ } catch (Exception e) {
+ log.error("execute:: tenantId: {}", tenantId, e);
+ throw new TenantScopedExecutionException(e, tenantId);
+ }
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 5fa89cd8..c3fef02e 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -43,6 +43,10 @@ folio:
enabled: true
environment: ${ENV:folio}
okapi-url: ${OKAPI_URL:http://okapi:9130}
+ logging:
+ feign:
+ enabled: true
+ level: full
management:
endpoints:
web:
diff --git a/src/main/resources/db/changelog/changes/initial_schema.xml b/src/main/resources/db/changelog/changes/initial_schema.xml
index b8aa6165..d0ea0ae6 100644
--- a/src/main/resources/db/changelog/changes/initial_schema.xml
+++ b/src/main/resources/db/changelog/changes/initial_schema.xml
@@ -17,6 +17,7 @@
+
diff --git a/src/main/resources/log4j2.properties b/src/main/resources/log4j2.properties
new file mode 100644
index 00000000..6fbbd253
--- /dev/null
+++ b/src/main/resources/log4j2.properties
@@ -0,0 +1,15 @@
+status = error
+name = PropertiesConfig
+packages = org.folio.spring.logging
+
+appenders = console
+
+appender.console.type = Console
+appender.console.name = STDOUT
+
+appender.console.layout.type = PatternLayout
+appender.console.layout.pattern = %d{HH:mm:ss} [$${folio:requestid:-}] [$${folio:tenantid:-}] [$${folio:userid:-}] [$${folio:moduleid:-}] %-5p %-20.20C{1} %m%n
+
+rootLogger.level = debug
+rootLogger.appenderRefs = debug
+rootLogger.appenderRef.stdout.ref = STDOUT
\ No newline at end of file
diff --git a/src/main/resources/swagger.api/ecs-tlr.yaml b/src/main/resources/swagger.api/ecs-tlr.yaml
index 3a2064a0..657e0354 100644
--- a/src/main/resources/swagger.api/ecs-tlr.yaml
+++ b/src/main/resources/swagger.api/ecs-tlr.yaml
@@ -45,7 +45,9 @@ components:
ecs-tlr:
$ref: 'schemas/EcsTlr.yaml#/EcsTlr'
errorResponse:
- $ref: schemas/errors.json
+ $ref: 'schemas/errors.json'
+ request:
+ $ref: 'schemas/request.json'
parameters:
requestId:
name: requestId
diff --git a/src/main/resources/swagger.api/schemas/EcsTlr.yaml b/src/main/resources/swagger.api/schemas/EcsTlr.yaml
index ca60b510..3405bf5e 100644
--- a/src/main/resources/swagger.api/schemas/EcsTlr.yaml
+++ b/src/main/resources/swagger.api/schemas/EcsTlr.yaml
@@ -23,6 +23,10 @@ EcsTlr:
description: "Date when the request expires"
type: string
format: date-time
+ requestDate:
+ description: "Date when the request was placed"
+ type: string
+ format: date-time
patronComments:
description: "Comments made by the patron"
type: string
@@ -42,3 +46,4 @@ EcsTlr:
- requestType
- requestLevel
- fulfillmentPreference
+ - requestDate
diff --git a/src/main/resources/swagger.api/schemas/override-blocks.json b/src/main/resources/swagger.api/schemas/override-blocks.json
new file mode 100644
index 00000000..51985abd
--- /dev/null
+++ b/src/main/resources/swagger.api/schemas/override-blocks.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "type": "object",
+ "description": "Blocks to override (e.g. during checkout or renewal)",
+ "properties": {
+ "itemNotLoanableBlock": {
+ "description": "'Item not loanable' block",
+ "type": "object",
+ "properties": {
+ "dueDate": {
+ "description": "Due date for a new loan",
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "dueDate"
+ ]
+ },
+ "patronBlock": {
+ "description": "Automated patron block",
+ "type": "object",
+ "additionalProperties": false
+ },
+ "itemLimitBlock": {
+ "description": "Item limit block",
+ "type": "object",
+ "additionalProperties": false
+ },
+ "renewalBlock": {
+ "description": "Renewal block",
+ "type": "object",
+ "additionalProperties": false
+ },
+ "renewalDueDateRequiredBlock": {
+ "description": "Override renewal block which requires due date field",
+ "type": "object",
+ "properties": {
+ "dueDate": {
+ "description": "Due date for a new loan",
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "dueDate"
+ ]
+ },
+ "comment": {
+ "description": "Reason for override",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/src/main/resources/swagger.api/schemas/request-search-index.json b/src/main/resources/swagger.api/schemas/request-search-index.json
new file mode 100644
index 00000000..25ff8c84
--- /dev/null
+++ b/src/main/resources/swagger.api/schemas/request-search-index.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "description": "Request fields used for search",
+ "type": "object",
+ "properties": {
+ "callNumberComponents": {
+ "type": "object",
+ "description": "Effective call number components",
+ "properties": {
+ "callNumber": {
+ "type": "string",
+ "description": "Effective Call Number is an identifier assigned to an item or its holding and associated with the item."
+ },
+ "prefix": {
+ "type": "string",
+ "description": "Effective Call Number Prefix is the prefix of the identifier assigned to an item or its holding and associated with the item."
+ },
+ "suffix": {
+ "type": "string",
+ "description": "Effective Call Number Suffix is the suffix of the identifier assigned to an item or its holding and associated with the item."
+ }
+ },
+ "additionalProperties": false
+ },
+ "shelvingOrder": {
+ "type": "string",
+ "description": "A system generated normalization of the call number that allows for call number sorting in reports and search results"
+ },
+ "pickupServicePointName": {
+ "description": "The name of the request pickup service point",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/src/main/resources/swagger.api/schemas/request.json b/src/main/resources/swagger.api/schemas/request.json
new file mode 100644
index 00000000..cb43c1f3
--- /dev/null
+++ b/src/main/resources/swagger.api/schemas/request.json
@@ -0,0 +1,395 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "title": "A request for an item",
+ "description": "Request for an item that might be at a different location or already checked out to another patron",
+ "type": "object",
+ "properties": {
+ "id": {
+ "description": "UUID of the request",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "requestType": {
+ "description": "Whether the item should be held upon return, recalled or paged for",
+ "type": "string",
+ "enum": ["Hold", "Recall", "Page"]
+ },
+ "requestLevel": {
+ "description": "Level of the request - Item or Title",
+ "type": "string",
+ "enum": ["Item", "Title"]
+ },
+ "requestDate": {
+ "description": "Date the request was made",
+ "type": "string",
+ "format": "date-time"
+ },
+ "patronComments": {
+ "description": "Comments made by the patron",
+ "type": "string"
+ },
+ "requesterId": {
+ "description": "ID of the user who made the request",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "proxyUserId": {
+ "description": "ID of the user representing a proxy for the patron",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "instanceId": {
+ "description": "ID of the instance being requested",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "holdingsRecordId": {
+ "description": "ID of the holdings record being requested",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "itemId": {
+ "description": "ID of the item being requested",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "status": {
+ "description": "Status of the request",
+ "type": "string",
+ "enum": [
+ "Open - Not yet filled",
+ "Open - Awaiting pickup",
+ "Open - In transit",
+ "Open - Awaiting delivery",
+ "Closed - Filled",
+ "Closed - Cancelled",
+ "Closed - Unfilled",
+ "Closed - Pickup expired"
+ ]
+ },
+ "cancellationReasonId": {
+ "description": "The id of the request reason",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "cancelledByUserId": {
+ "description": "The id of the user that cancelled the request",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "cancellationAdditionalInformation": {
+ "description": "Additional information about a cancellation",
+ "type": "string"
+ },
+ "cancelledDate": {
+ "description": "Date the request was cancelled",
+ "type": "string",
+ "format": "date-time"
+ },
+ "position": {
+ "description": "position of the request in a per-item request queue",
+ "type": "integer",
+ "minimum": 1
+ },
+ "instance": {
+ "description": "Copy of some instance metadata (used for searching and sorting)",
+ "type": "object",
+ "properties": {
+ "title": {
+ "description": "title of the item",
+ "type": "string"
+ },
+ "identifiers": {
+ "type": "array",
+ "description": "An extensible set of name-value pairs of identifiers associated with the resource",
+ "minItems": 0,
+ "items": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "string",
+ "description": "Resource identifier value"
+ },
+ "identifierTypeId": {
+ "type": "string",
+ "description": "UUID of resource identifier type (e.g. ISBN, ISSN, LCCN, CODEN, Locally defined identifiers)",
+ "$ref": "uuid.json"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "value",
+ "identifierTypeId"
+ ]
+ }
+ }
+ }
+ },
+ "item": {
+ "description": "Copy of some item metadata (used for searching and sorting)",
+ "type": "object",
+ "properties": {
+ "barcode": {
+ "description": "barcode of the item",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "requester": {
+ "description": "Copy of some requesting patron metadata (used for searching and sorting), will be taken from the user referred to by the requesterId",
+ "readonly": true,
+ "type": "object",
+ "properties": {
+ "firstName": {
+ "description": "first name of the patron (read only, defined by the server)",
+ "type": "string",
+ "readonly": true
+ },
+ "lastName": {
+ "description": "last name of the patron (read only, defined by the server)",
+ "type": "string",
+ "readonly": true
+ },
+ "middleName": {
+ "description": "middle name of the patron (read only, defined by the server)",
+ "type": "string",
+ "readonly": true
+ },
+ "barcode": {
+ "description": "barcode of the patron (read only, defined by the server)",
+ "type": "string",
+ "readonly": true
+ },
+ "patronGroupId": {
+ "description": "UUID for the patron group that this user belongs to",
+ "type": "string",
+ "readonly": true,
+ "$ref": "uuid.json"
+ },
+ "patronGroup": {
+ "description": "record for the user's patron group",
+ "type": "object",
+ "additionalProperties": false,
+ "readonly": true,
+ "properties": {
+ "id": {
+ "description": "ID of the patron group",
+ "type": "string",
+ "readonly": true,
+ "$ref": "uuid.json"
+ },
+ "group": {
+ "description": "The unique name of the patron group",
+ "type": "string",
+ "readonly": true
+ },
+ "desc": {
+ "description": "A description of the patron group",
+ "type": "string",
+ "readonly": true
+ }
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "proxy": {
+ "description": "Copy of some proxy patron metadata (used for searching and sorting), will be taken from the user referred to by the proxyUserId",
+ "readonly": true,
+ "type": "object",
+ "properties": {
+ "firstName": {
+ "description": "first name of the proxy patron (read only, defined by the server)",
+ "type": "string",
+ "readonly": true
+ },
+ "lastName": {
+ "description": "last name of the proxy patron (read only, defined by the server)",
+ "type": "string",
+ "readonly": true
+ },
+ "middleName": {
+ "description": "middle name of the proxy patron (read only, defined by the server)",
+ "type": "string",
+ "readonly": true
+ },
+ "barcode": {
+ "description": "barcode of the proxy patron (read only, defined by the server)",
+ "type": "string",
+ "readonly": true
+ },
+ "patronGroupId": {
+ "description": "UUID for the patrongroup that this user belongs to",
+ "type": "string",
+ "readonly": true,
+ "$ref": "uuid.json"
+ },
+ "patronGroup": {
+ "description": "record for the user's patrongroup",
+ "type": "object",
+ "readonly": true,
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "description": "ID of the patrongroup",
+ "type": "string",
+ "readonly": true,
+ "$ref": "uuid.json"
+ },
+ "group": {
+ "description": "The unique name of the patrongroup",
+ "type": "string",
+ "readonly": true
+ },
+ "desc": {
+ "description": "A description of the patrongroup",
+ "type": "string",
+ "readonly": true
+ }
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "fulfillmentPreference": {
+ "description": "How should the request be fulfilled (whether the item should be kept on the hold shelf for collection or delivered to the requester)",
+ "type": "string",
+ "enum": ["Hold Shelf", "Delivery"]
+ },
+ "deliveryAddressTypeId": {
+ "description": "Deliver to the address of this type, for the requesting patron",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "deliveryAddress": {
+ "description": "Address the item is to be delivered to (derived from requester information)",
+ "type": "object",
+ "readonly": true,
+ "properties": {
+ "addressLine1": {
+ "description": "Address line 1",
+ "type": "string",
+ "readonly": true
+ },
+ "addressLine2": {
+ "description": "Address line 2",
+ "type": "string",
+ "readonly": true
+ },
+ "city": {
+ "description": "City name",
+ "type": "string",
+ "readonly": true
+ },
+ "region": {
+ "description": "Region",
+ "type": "string",
+ "readonly": true
+ },
+ "postalCode": {
+ "description": "Postal code",
+ "type": "string",
+ "readonly": true
+ },
+ "countryId": {
+ "description": "Country code",
+ "type": "string",
+ "readonly": true
+ },
+ "addressTypeId": {
+ "description": "Type of address (refers to address types)",
+ "type": "string",
+ "readonly": true,
+ "$ref": "uuid.json"
+ }
+ },
+ "additionalProperties": false
+ },
+ "requestExpirationDate": {
+ "description": "Date when the request expires",
+ "type": "string",
+ "format": "date-time"
+ },
+ "holdShelfExpirationDate": {
+ "description": "Date when an item returned to the hold shelf expires",
+ "type": "string",
+ "format": "date-time"
+ },
+ "pickupServicePointId": {
+ "description": "The ID of the Service Point where this request can be picked up",
+ "type": "string",
+ "$ref": "uuid.json"
+ },
+ "pickupServicePoint": {
+ "description": "The full object of the Service Point record from pickupServicePointId",
+ "additionalProperties": false,
+ "readonly": true,
+ "properties": {
+ "name": {
+ "description": "Unique name for the service point",
+ "type": "string",
+ "readonly": true
+ },
+ "code": {
+ "description": "Unique code for the service point",
+ "type": "string",
+ "readonly": true
+ },
+ "discoveryDisplayName": {
+ "description": "Human-readable name for the service point",
+ "type": "string",
+ "readonly": true
+ },
+ "description": {
+ "description": "Description of the service point",
+ "type": "string",
+ "readonly": true
+ },
+ "shelvingLagTime": {
+ "description": "Shelving lag time",
+ "type": "integer",
+ "readonly": true
+ },
+ "pickupLocation": {
+ "description": "Is this service point a pickup location?",
+ "type": "boolean",
+ "readonly": true
+ }
+ }
+ },
+ "tags": {
+ "type": "object",
+ "description": "Tags",
+ "$ref": "tags.json"
+ },
+ "metadata": {
+ "description": "Metadata about creation and changes to requests, provided by the server (client should not provide)",
+ "type": "object",
+ "$ref": "metadata.json"
+ },
+ "requestProcessingParameters": {
+ "type": "object",
+ "description": "Additional parameters used for request processing and discarded afterwards. Not part of request record.",
+ "properties": {
+ "overrideBlocks": {
+ "type": "object",
+ "description": "Blocks to override if user has corresponding permissions",
+ "$ref": "override-blocks.json"
+ }
+ }
+ },
+ "searchIndex": {
+ "description": "Request fields used for search",
+ "type": "object",
+ "$ref": "request-search-index.json"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "requesterId",
+ "requestType",
+ "requestDate",
+ "fulfillmentPreference"
+ ]
+}
diff --git a/src/main/resources/swagger.api/schemas/tags.json b/src/main/resources/swagger.api/schemas/tags.json
new file mode 100644
index 00000000..cc7cfcfc
--- /dev/null
+++ b/src/main/resources/swagger.api/schemas/tags.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "title": "tags",
+ "description": "List of simple tags that can be added to an object",
+ "type": "object",
+ "properties": {
+ "tagList": {
+ "description": "List of tags",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/src/main/resources/swagger.api/schemas/uuid.json b/src/main/resources/swagger.api/schemas/uuid.json
new file mode 100644
index 00000000..1f907f9e
--- /dev/null
+++ b/src/main/resources/swagger.api/schemas/uuid.json
@@ -0,0 +1,5 @@
+{
+ "description": "A universally unique identifier (UUID), this is a 128-bit number used to identify a record and is shown in hex with dashes, for example 6312d172-f0cf-40f6-b27d-9fa8feaf332f; the UUID version must be from 1-5; see https://dev.folio.org/guides/uuids/",
+ "type": "string",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
+}
diff --git a/src/test/java/org/folio/api/BaseIT.java b/src/test/java/org/folio/api/BaseIT.java
index e34adfc0..73e1103a 100644
--- a/src/test/java/org/folio/api/BaseIT.java
+++ b/src/test/java/org/folio/api/BaseIT.java
@@ -3,6 +3,7 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
import com.github.tomakehurst.wiremock.WireMockServer;
import lombok.SneakyThrows;
import org.folio.spring.integration.XOkapiHeaders;
@@ -14,20 +15,25 @@
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.support.TestPropertySourceUtils;
import org.springframework.test.util.TestSocketUtils;
+import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.web.reactive.function.BodyInserters;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.util.List;
+import java.util.UUID;
import static org.springframework.http.MediaType.APPLICATION_JSON;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@@ -38,14 +44,18 @@
public class BaseIT {
@Autowired
protected MockMvc mockMvc;
- public static WireMockServer wireMockServer;
+ protected static WireMockServer wireMockServer;
protected static final String TOKEN = "test_token";
- public static final String TENANT = "diku";
- protected static PostgreSQLContainer> postgresDBContainer = new PostgreSQLContainer<>("postgres:12-alpine");
- public final static int WIRE_MOCK_PORT = TestSocketUtils.findAvailableTcpPort();
- protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL)
+ protected static final String TENANT = "diku";
+ private static final PostgreSQLContainer> postgresDBContainer = new PostgreSQLContainer<>("postgres:12-alpine");
+ private static final int WIRE_MOCK_PORT = TestSocketUtils.findAvailableTcpPort();
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
- .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
+ .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
+ .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ @LocalServerPort
+ private int serverPort;
+ private WebTestClient webClient;
static {
postgresDBContainer.start();
@@ -69,7 +79,7 @@ static void tearDown() {
@SneakyThrows
protected static void setUpTenant(MockMvc mockMvc) {
- mockMvc.perform(post("/_/tenant")
+ mockMvc.perform(MockMvcRequestBuilders.post("/_/tenant")
.content(asJsonString(new TenantAttributes().moduleTo("mod-tlr")))
.headers(defaultHeaders())
.contentType(APPLICATION_JSON)).andExpect(status().isNoContent());
@@ -102,4 +112,33 @@ public void initialize(@NotNull ConfigurableApplicationContext applicationContex
}
}
+ protected WebTestClient webClient() {
+ if (webClient == null) {
+ webClient = WebTestClient.bindToServer()
+ .baseUrl("http://localhost:" + serverPort).build();
+ }
+ return webClient;
+ }
+
+ protected WebTestClient.RequestBodySpec buildRequest(HttpMethod method, String uri) {
+ return webClient().method(method)
+ .uri(uri)
+ .accept(APPLICATION_JSON)
+ .contentType(APPLICATION_JSON)
+ .header(XOkapiHeaders.TENANT, TENANT)
+ .header(XOkapiHeaders.URL, wireMockServer.baseUrl())
+ .header(XOkapiHeaders.TOKEN, TOKEN)
+ .header(XOkapiHeaders.USER_ID, randomId());
+ }
+
+ protected WebTestClient.ResponseSpec doPost(String url, Object payload) {
+ return buildRequest(HttpMethod.POST, url)
+ .body(BodyInserters.fromValue(payload))
+ .exchange();
+ }
+
+ protected static String randomId() {
+ return UUID.randomUUID().toString();
+ }
+
}
diff --git a/src/test/java/org/folio/api/EcsTlrApiTest.java b/src/test/java/org/folio/api/EcsTlrApiTest.java
index 71e8e898..0aa3fecc 100644
--- a/src/test/java/org/folio/api/EcsTlrApiTest.java
+++ b/src/test/java/org/folio/api/EcsTlrApiTest.java
@@ -1,20 +1,112 @@
package org.folio.api;
+import org.apache.http.HttpStatus;
+import org.folio.domain.dto.EcsTlr;
+import org.folio.spring.FolioExecutionContext;
+import org.folio.spring.FolioModuleMetadata;
+import org.folio.spring.scope.FolioExecutionContextSetter;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.MediaType;
+
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
import java.util.UUID;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.jsonResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching;
+import static java.util.stream.Collectors.toMap;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import com.github.tomakehurst.wiremock.client.WireMock;
+
class EcsTlrApiTest extends BaseIT {
- private static final String TLR_URL = "/tlr/ecs-tlr/";
+ private static final String TLR_URL = "/tlr/ecs-tlr";
+ private static final String ANOTHER_TENANT = "university";
+ private static final String TENANT_HEADER = "x-okapi-tenant";
+
+ @Autowired
+ private FolioExecutionContext context;
+ @Autowired
+ private FolioModuleMetadata moduleMetadata;
+ @Autowired
+ private TestRestTemplate restTemplate;
+ private FolioExecutionContextSetter contextSetter;
+
+ @BeforeEach
+ public void beforeEach() {
+ contextSetter = initContext();
+ }
+
+ @AfterEach
+ public void afterEach() {
+ contextSetter.close();
+ }
@Test
void getByIdNotFound() throws Exception {
mockMvc.perform(
- get(TLR_URL + UUID.randomUUID())
+ get(TLR_URL + "/" + UUID.randomUUID())
.headers(defaultHeaders())
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
+
+ @Test
+ void titleLevelRequestIsCreatedForDifferentTenant() {
+ EcsTlr ecsTlr = buildEcsTlr();
+ String ecsTlrJson = asJsonString(ecsTlr);
+ wireMockServer.stubFor(WireMock.post(urlMatching(".*/circulation/requests"))
+ .withHeader(TENANT_HEADER, equalTo(ANOTHER_TENANT))
+ .willReturn(jsonResponse(ecsTlrJson, HttpStatus.SC_CREATED)));
+ assertEquals(TENANT, getCurrentTenantId());
+
+ doPost(TLR_URL, ecsTlr)
+ .expectStatus().isCreated()
+ .expectBody().json(ecsTlrJson);
+
+ assertEquals(TENANT, getCurrentTenantId());
+ wireMockServer.verify(postRequestedFor(urlMatching(".*/circulation/requests"))
+ .withHeader(TENANT_HEADER, equalTo(ANOTHER_TENANT))
+ .withRequestBody(equalToJson(ecsTlrJson)));
+ }
+
+ private String getCurrentTenantId() {
+ return context.getTenantId();
+ }
+
+ private static Map> buildDefaultHeaders() {
+ return new HashMap<>(defaultHeaders().entrySet()
+ .stream()
+ .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)));
+ }
+
+ private FolioExecutionContextSetter initContext() {
+ return new FolioExecutionContextSetter(moduleMetadata, buildDefaultHeaders());
+ }
+
+ private static EcsTlr buildEcsTlr() {
+ return new EcsTlr()
+ .id(randomId())
+ .itemId(randomId())
+ .instanceId(randomId())
+ .requesterId(randomId())
+ .pickupServicePointId(randomId())
+ .fulfillmentPreference(EcsTlr.FulfillmentPreferenceEnum.DELIVERY)
+ .patronComments("random comment")
+ .requestExpirationDate(new Date())
+ .requestType(EcsTlr.RequestTypeEnum.PAGE)
+ .requestLevel(EcsTlr.RequestLevelEnum.TITLE);
+ }
+
}
diff --git a/src/test/java/org/folio/controller/EcsTlrControllerTest.java b/src/test/java/org/folio/controller/EcsTlrControllerTest.java
index f7f5021b..5d99b86e 100644
--- a/src/test/java/org/folio/controller/EcsTlrControllerTest.java
+++ b/src/test/java/org/folio/controller/EcsTlrControllerTest.java
@@ -5,7 +5,6 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.CREATED;
-import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import java.util.Optional;
diff --git a/src/test/java/org/folio/controller/KafkaEventListenerTest.java b/src/test/java/org/folio/controller/KafkaEventListenerTest.java
index a1df4c60..88184f33 100644
--- a/src/test/java/org/folio/controller/KafkaEventListenerTest.java
+++ b/src/test/java/org/folio/controller/KafkaEventListenerTest.java
@@ -93,7 +93,7 @@ private static void publishEvent(String topic, String payload) {
private static void waitForOffset(String topic, String consumerGroupId, int expectedOffset) {
Awaitility.await()
- .atMost(30, TimeUnit.SECONDS)
+ .atMost(60, TimeUnit.SECONDS)
.until(() -> getOffset(topic, consumerGroupId), offset -> offset.equals(expectedOffset));
}
diff --git a/src/test/java/org/folio/service/EcsTlrServiceTest.java b/src/test/java/org/folio/service/EcsTlrServiceTest.java
index 4eaf0752..f2904003 100644
--- a/src/test/java/org/folio/service/EcsTlrServiceTest.java
+++ b/src/test/java/org/folio/service/EcsTlrServiceTest.java
@@ -8,6 +8,7 @@
import java.util.UUID;
import org.folio.domain.dto.EcsTlr;
+import org.folio.domain.dto.Request;
import org.folio.domain.entity.EcsTlrEntity;
import org.folio.domain.mapper.EcsTlrMapper;
import org.folio.domain.mapper.EcsTlrMapperImpl;
@@ -28,6 +29,8 @@ class EcsTlrServiceTest {
private EcsTlrServiceImpl ecsTlrService;
@Mock
private EcsTlrRepository ecsTlrRepository;
+ @Mock
+ private TenantScopedExecutionService tenantScopedExecutionService;
@Spy
private final EcsTlrMapper ecsTlrMapper = new EcsTlrMapperImpl();
@@ -46,7 +49,8 @@ void postEcsTlr() {
var requestType = EcsTlr.RequestTypeEnum.PAGE;
var requestLevel = EcsTlr.RequestLevelEnum.TITLE;
var fulfillmentPreference = EcsTlr.FulfillmentPreferenceEnum.HOLD_SHELF;
- var requestExpirationDate = DateTime.now().toDate();
+ var requestExpirationDate = DateTime.now().plusDays(7).toDate();
+ var requestDate = DateTime.now().toDate();
var patronComments = "Test comment";
var mockEcsTlrEntity = new EcsTlrEntity();
@@ -56,6 +60,7 @@ void postEcsTlr() {
mockEcsTlrEntity.setRequestType(requestType.toString());
mockEcsTlrEntity.setRequestLevel(requestLevel.getValue());
mockEcsTlrEntity.setRequestExpirationDate(requestExpirationDate);
+ mockEcsTlrEntity.setRequestDate(requestDate);
mockEcsTlrEntity.setPatronComments(patronComments);
mockEcsTlrEntity.setFulfillmentPreference(fulfillmentPreference.getValue());
mockEcsTlrEntity.setPickupServicePointId(pickupServicePointId);
@@ -67,11 +72,14 @@ void postEcsTlr() {
ecsTlr.setRequestType(requestType);
ecsTlr.setRequestLevel(requestLevel);
ecsTlr.setRequestExpirationDate(requestExpirationDate);
+ ecsTlr.setRequestDate(requestDate);
ecsTlr.setPatronComments(patronComments);
ecsTlr.setFulfillmentPreference(fulfillmentPreference);
ecsTlr.setPickupServicePointId(pickupServicePointId.toString());
when(ecsTlrRepository.save(any(EcsTlrEntity.class))).thenReturn(mockEcsTlrEntity);
+ when(tenantScopedExecutionService.execute(any(String.class), any()))
+ .thenReturn(new Request().id(UUID.randomUUID().toString()));
var postEcsTlr = ecsTlrService.post(ecsTlr);
assertEquals(id.toString(), postEcsTlr.getId());
@@ -79,6 +87,7 @@ void postEcsTlr() {
assertEquals(requesterId.toString(), postEcsTlr.getRequesterId());
assertEquals(requestType, postEcsTlr.getRequestType());
assertEquals(requestExpirationDate, postEcsTlr.getRequestExpirationDate());
+ assertEquals(requestDate, postEcsTlr.getRequestDate());
assertEquals(patronComments, postEcsTlr.getPatronComments());
assertEquals(fulfillmentPreference, postEcsTlr.getFulfillmentPreference());
assertEquals(pickupServicePointId.toString(), postEcsTlr.getPickupServicePointId());
diff --git a/src/test/java/org/folio/service/TenantScopedExecutionServiceTest.java b/src/test/java/org/folio/service/TenantScopedExecutionServiceTest.java
new file mode 100644
index 00000000..937d3114
--- /dev/null
+++ b/src/test/java/org/folio/service/TenantScopedExecutionServiceTest.java
@@ -0,0 +1,44 @@
+package org.folio.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+
+import org.folio.exception.TenantScopedExecutionException;
+import org.folio.service.impl.TenantScopedExecutionServiceImpl;
+import org.folio.spring.FolioExecutionContext;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class TenantScopedExecutionServiceTest {
+
+ @Mock
+ private FolioExecutionContext folioExecutionContext;
+ @InjectMocks
+ private TenantScopedExecutionServiceImpl executionService;
+
+ @Test
+ void executionExceptionIsForwarded() {
+ when(folioExecutionContext.getAllHeaders()).thenReturn(new HashMap<>());
+ String tenantId = "test-tenant";
+ String errorMessage = "cause message";
+
+ TenantScopedExecutionException exception = assertThrows(TenantScopedExecutionException.class,
+ () -> executionService.execute(tenantId, () -> {
+ throw new IllegalAccessException(errorMessage);
+ }));
+
+ assertEquals(tenantId, exception.getTenantId());
+ assertNotNull(exception.getCause());
+ assertInstanceOf(IllegalAccessException.class, exception.getCause());
+ assertEquals(errorMessage, exception.getCause().getMessage());
+ }
+}
\ No newline at end of file