diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index b4a41bee3..757f604a6 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -92,6 +92,15 @@ "source-storage.records.put" ] }, + { + "methods": [ + "PUT" + ], + "pathPattern": "/source-storage/records/{id}/generation", + "permissionsRequired": [ + "source-storage.records.put" + ] + }, { "methods": [ "DELETE" diff --git a/mod-source-record-storage-server/src/main/java/org/folio/rest/impl/SourceStorageRecordsImpl.java b/mod-source-record-storage-server/src/main/java/org/folio/rest/impl/SourceStorageRecordsImpl.java index 8469b3d19..70bd30db8 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/rest/impl/SourceStorageRecordsImpl.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/rest/impl/SourceStorageRecordsImpl.java @@ -98,6 +98,21 @@ public void putSourceStorageRecordsById(String id, Record entity, Map okapiHeaders, + Handler> asyncResultHandler, Context vertxContext) { + vertxContext.runOnContext(v -> { + try { + recordService.updateRecordGeneration(matchedId, entity, tenantId) + .map(updated -> PutSourceStorageRecordsGenerationByIdResponse.respond200WithApplicationJson(entity)) + .map(Response.class::cast).otherwise(ExceptionHelper::mapExceptionToResponse) + .onComplete(asyncResultHandler); + } catch (Exception e) { + LOG.warn("putSourceStorageRecordsGenerationById:: Failed to update record generation by matchedId {}", matchedId, e); + asyncResultHandler.handle(Future.succeededFuture(ExceptionHelper.mapExceptionToResponse(e))); + } + }); + } @Override public void deleteSourceStorageRecordsById(String id, Map okapiHeaders, diff --git a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordService.java b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordService.java index 3e2235d18..8b7cf1899 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordService.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordService.java @@ -89,6 +89,16 @@ public interface RecordService { */ Future updateRecord(Record record, String tenantId); + /** + * Updates record generation with given matched id + * + * @param matchedId matched id + * @param record record to update + * @param tenantId tenant id + * @return future with updated Record generation + */ + Future updateRecordGeneration(String matchedId, Record record, String tenantId); + /** * Searches for {@link SourceRecord} by {@link Condition} and ordered by order fields with offset and limit * diff --git a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java index 30888f373..505ffed11 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java @@ -73,6 +73,9 @@ public class RecordServiceImpl implements RecordService { private final RecordDao recordDao; private static final String DUPLICATE_CONSTRAINT = "idx_records_matched_id_gen"; private static final String DUPLICATE_RECORD_MSG = "Incoming file may contain duplicates"; + private static final String MATCHED_ID_NOT_EQUAL_TO_999_FIELD = "Matched id (%s) not equal to 999ff$s (%s) field"; + private static final String RECORD_WITH_GIVEN_MATCHED_ID_NOT_FOUND = "Record with given matched id (%s) not found"; + public static final String UPDATE_RECORD_DUPLICATE_EXCEPTION = "Incoming record could be a duplicate, incoming record generation should not be the same as matched record generation and the execution of job should be started after of creating the previous record generation"; public static final char SUBFIELD_S = 's'; public static final char INDICATOR = 'f'; @@ -152,6 +155,25 @@ public Future updateRecord(Record record, String tenantId) { return recordDao.updateRecord(ensureRecordForeignKeys(record), tenantId); } + @Override + public Future updateRecordGeneration(String matchedId, Record record, String tenantId) { + String marcField999s = getFieldFromMarcRecord(record, TAG_999, INDICATOR, INDICATOR, SUBFIELD_S); + if (!matchedId.equals(marcField999s)) { + return Future.failedFuture(new BadRequestException(format(MATCHED_ID_NOT_EQUAL_TO_999_FIELD, matchedId, marcField999s))); + } + record.setId(UUID.randomUUID().toString()); + + return recordDao.getRecordById(matchedId, tenantId) + .map(r -> r.orElseThrow(() -> new NotFoundException(format(RECORD_WITH_GIVEN_MATCHED_ID_NOT_FOUND, matchedId)))) + .compose(v -> saveRecord(record, tenantId)) + .recover(throwable -> { + if (throwable instanceof DuplicateRecordException) { + return Future.failedFuture(new BadRequestException(UPDATE_RECORD_DUPLICATE_EXCEPTION)); + } + return Future.failedFuture(throwable); + }); + } + @Override public Future getSourceRecords(Condition condition, RecordType recordType, Collection> orderFields, int offset, int limit, String tenantId) { diff --git a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordApiTest.java b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordApiTest.java index 1cfa3e26e..3761a3ef9 100644 --- a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordApiTest.java +++ b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordApiTest.java @@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.everyItem; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; @@ -12,7 +13,6 @@ import java.util.UUID; import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.collect.Lists; import io.restassured.RestAssured; import io.restassured.response.Response; import io.vertx.core.json.JsonArray; @@ -50,12 +50,14 @@ public class RecordApiTest extends AbstractRestVerticleTest { private static final String SIXTH_UUID = UUID.randomUUID().toString(); private static final String SEVENTH_UUID = UUID.randomUUID().toString(); private static final String EIGHTH_UUID = UUID.randomUUID().toString(); + private static final String GENERATION = "generation"; private static RawRecord rawMarcRecord; private static ParsedRecord parsedMarcRecord; private static RawRecord rawEdifactRecord; private static ParsedRecord parsedEdifactRecord; + private static ParsedRecord parsedMarcRecordWith999ff$s; static { try { @@ -67,6 +69,13 @@ public class RecordApiTest extends AbstractRestVerticleTest { .withContent(new ObjectMapper().readValue(TestUtil.readFileFromPath(RAW_EDIFACT_RECORD_CONTENT_SAMPLE_PATH), String.class)); parsedEdifactRecord = new ParsedRecord() .withContent(new ObjectMapper().readValue(TestUtil.readFileFromPath(PARSED_EDIFACT_RECORD_CONTENT_SAMPLE_PATH), JsonObject.class).encode()); + parsedMarcRecordWith999ff$s = new ParsedRecord().withId(FIRST_UUID) + .withContent(new JsonObject().put("leader", "01542ccm a2200361 4500") + .put("fields", new JsonArray().add(new JsonObject().put("999", new JsonObject() + .put("subfields", + new JsonArray().add(new JsonObject().put("s", FIRST_UUID))) + .put("ind1", "f") + .put("ind2", "f")))).encode()); } catch (IOException e) { e.printStackTrace(); } @@ -589,6 +598,120 @@ public void shouldUpdateErrorRecordOnPut(TestContext testContext) { async.complete(); } + @Test + public void shouldSendBadRequestWhen999ff$sIsNullDuringUpdateRecordGeneration(TestContext testContext) { + postSnapshots(testContext, snapshot_1); + + Async async = testContext.async(); + Response createResponse = RestAssured.given() + .spec(spec) + .body(record_1) + .when() + .post(SOURCE_STORAGE_RECORDS_PATH); + assertThat(createResponse.statusCode(), is(HttpStatus.SC_CREATED)); + Record createdRecord = createResponse.body().as(Record.class); + + RestAssured.given() + .spec(spec) + .body(createdRecord) + .when() + .put(SOURCE_STORAGE_RECORDS_PATH + "/" + createdRecord.getId() + "/" + GENERATION) + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST); + async.complete(); + } + + @Test + public void shouldSendBadRequestWhenMatchedIfNotEqualTo999ff$sDuringUpdateRecordGeneration(TestContext testContext) { + postSnapshots(testContext, snapshot_1); + + Async async = testContext.async(); + Response createResponse = RestAssured.given() + .spec(spec) + .body(record_1.withParsedRecord(parsedMarcRecordWith999ff$s)) + .when() + .post(SOURCE_STORAGE_RECORDS_PATH); + assertThat(createResponse.statusCode(), is(HttpStatus.SC_CREATED)); + Record createdRecord = createResponse.body().as(Record.class); + + RestAssured.given() + .spec(spec) + .body(createdRecord) + .when() + .put(SOURCE_STORAGE_RECORDS_PATH + "/" + UUID.randomUUID() + "/" + GENERATION) + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST); + async.complete(); + } + + @Test + public void shouldSendNotFoundWhenUpdateRecordGenerationForNonExistingRecord(TestContext testContext) { + Async async = testContext.async(); + RestAssured.given() + .spec(spec) + .body(record_1.withParsedRecord(parsedMarcRecordWith999ff$s)) + .when() + .put(SOURCE_STORAGE_RECORDS_PATH + "/" + record_1.getMatchedId() + "/" + GENERATION) + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + async.complete(); + } + + @Test + public void shouldSendBadRequestWhenUpdateRecordGenerationWithDuplicate(TestContext testContext) { + postSnapshots(testContext, snapshot_1); + + Async async = testContext.async(); + Response createResponse = RestAssured.given() + .spec(spec) + .body(record_1.withParsedRecord(parsedMarcRecordWith999ff$s)) + .when() + .post(SOURCE_STORAGE_RECORDS_PATH); + assertThat(createResponse.statusCode(), is(HttpStatus.SC_CREATED)); + Record createdRecord = createResponse.body().as(Record.class); + + postSnapshots(testContext, snapshot_2); + Record recordForUpdate = createdRecord.withSnapshotId(snapshot_2.getJobExecutionId()); + + RestAssured.given() + .spec(spec) + .body(recordForUpdate) + .when() + .put(SOURCE_STORAGE_RECORDS_PATH + "/" + createdRecord.getMatchedId() + "/" + GENERATION) + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST); + async.complete(); + } + + @Test + public void shouldUpdateRecordGeneration(TestContext testContext) { + postSnapshots(testContext, snapshot_1); + + Async async = testContext.async(); + Response createResponse = RestAssured.given() + .spec(spec) + .body(record_1.withParsedRecord(parsedMarcRecordWith999ff$s)) + .when() + .post(SOURCE_STORAGE_RECORDS_PATH); + assertThat(createResponse.statusCode(), is(HttpStatus.SC_CREATED)); + Record createdRecord = createResponse.body().as(Record.class); + + postSnapshots(testContext, snapshot_2); + Record recordForUpdate = createdRecord.withSnapshotId(snapshot_2.getJobExecutionId()).withGeneration(null); + + RestAssured.given() + .spec(spec) + .body(recordForUpdate) + .when() + .put(SOURCE_STORAGE_RECORDS_PATH + "/" + createdRecord.getMatchedId() + "/" + GENERATION) + .then() + .statusCode(HttpStatus.SC_OK) + .body("id", not(createdRecord.getId())) + .body("matchedId", is(recordForUpdate.getMatchedId())) + .body("generation", is(1)); + async.complete(); + } + @Test public void shouldReturnNotFoundOnGetByIdWhenRecordDoesNotExist() { RestAssured.given() diff --git a/mod-source-record-storage-server/src/test/java/org/folio/services/RecordServiceTest.java b/mod-source-record-storage-server/src/test/java/org/folio/services/RecordServiceTest.java index 6b0863e02..a1da2b8e1 100644 --- a/mod-source-record-storage-server/src/test/java/org/folio/services/RecordServiceTest.java +++ b/mod-source-record-storage-server/src/test/java/org/folio/services/RecordServiceTest.java @@ -35,6 +35,7 @@ import org.folio.rest.jaxrs.model.Record.State; import org.folio.rest.jaxrs.model.RecordCollection; import org.folio.rest.jaxrs.model.RecordsBatchResponse; +import org.folio.rest.jaxrs.model.Snapshot; import org.folio.rest.jaxrs.model.SourceRecord; import org.folio.rest.jaxrs.model.SourceRecordCollection; import org.folio.rest.jaxrs.model.StrippedParsedRecord; @@ -49,11 +50,14 @@ import org.junit.Test; import org.junit.runner.RunWith; +import javax.ws.rs.BadRequestException; +import javax.ws.rs.NotFoundException; import java.io.IOException; import java.time.OffsetDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Objects; import java.util.UUID; @@ -381,6 +385,226 @@ public void shouldSaveMarcBibRecordWithMatchedIdFrom999field(TestContext context }); } + @Test + public void shouldFailDuringUpdateRecordGenerationIfIncomingMatchedIdNotEqualToMatchedIdFrom999field(TestContext context) { + String matchedId = UUID.randomUUID().toString(); + String marc999 = UUID.randomUUID().toString(); + Record original = TestMocks.getMarcBibRecord(); + ParsedRecord parsedRecord = new ParsedRecord().withId(marc999) + .withContent(new JsonObject().put("leader", "01542ccm a2200361 4500") + .put("fields", new JsonArray().add(new JsonObject().put("999", new JsonObject() + .put("subfields", + new JsonArray().add(new JsonObject().put("s", marc999))) + .put("ind1", "f") + .put("ind2", "f")))).encode()); + Record record = new Record() + .withId(UUID.randomUUID().toString()) + .withSnapshotId(original.getSnapshotId()) + .withRecordType(original.getRecordType()) + .withState(State.ACTUAL) + .withOrder(original.getOrder()) + .withRawRecord(original.getRawRecord()) + .withParsedRecord(parsedRecord) + .withAdditionalInfo(original.getAdditionalInfo()) + .withExternalIdsHolder(new ExternalIdsHolder().withInstanceId(UUID.randomUUID().toString())) + .withMetadata(original.getMetadata()); + Async async = context.async(); + + recordService.updateRecordGeneration(matchedId, record, TENANT_ID).onComplete(save -> { + context.assertTrue(save.failed()); + context.assertTrue(save.cause() instanceof BadRequestException); + recordDao.getRecordByMatchedId(matchedId, TENANT_ID).onComplete(get -> { + if (get.failed()) { + context.fail(get.cause()); + } + context.assertTrue(get.result().isEmpty()); + async.complete(); + }); + }); + } + + @Test + public void shouldFailDuringUpdateRecordGenerationIfRecordWithIdAsIncomingMatchedIfNotExist(TestContext context) { + String matchedId = UUID.randomUUID().toString(); + Record original = TestMocks.getMarcBibRecord(); + ParsedRecord parsedRecord = new ParsedRecord().withId(matchedId) + .withContent(new JsonObject().put("leader", "01542ccm a2200361 4500") + .put("fields", new JsonArray().add(new JsonObject().put("999", new JsonObject() + .put("subfields", + new JsonArray().add(new JsonObject().put("s", matchedId))) + .put("ind1", "f") + .put("ind2", "f")))).encode()); + Record record = new Record() + .withId(UUID.randomUUID().toString()) + .withSnapshotId(original.getSnapshotId()) + .withRecordType(original.getRecordType()) + .withState(State.ACTUAL) + .withOrder(original.getOrder()) + .withRawRecord(original.getRawRecord()) + .withParsedRecord(parsedRecord) + .withAdditionalInfo(original.getAdditionalInfo()) + .withExternalIdsHolder(new ExternalIdsHolder().withInstanceId(UUID.randomUUID().toString())) + .withMetadata(original.getMetadata()); + Async async = context.async(); + + recordService.updateRecordGeneration(matchedId, record, TENANT_ID).onComplete(save -> { + context.assertTrue(save.failed()); + context.assertTrue(save.cause() instanceof NotFoundException); + recordDao.getRecordByMatchedId(matchedId, TENANT_ID).onComplete(get -> { + if (get.failed()) { + context.fail(get.cause()); + } + context.assertTrue(get.result().isEmpty()); + async.complete(); + }); + }); + } + + @Test + public void shouldFailUpdateRecordGenerationIfDuplicateError(TestContext context) { + String matchedId = UUID.randomUUID().toString(); + Record original = TestMocks.getMarcBibRecord(); + + Record record1 = new Record() + .withId(matchedId) + .withSnapshotId(original.getSnapshotId()) + .withRecordType(original.getRecordType()) + .withState(State.ACTUAL) + .withOrder(original.getOrder()) + .withRawRecord(rawRecord) + .withParsedRecord(marcRecord) + .withAdditionalInfo(original.getAdditionalInfo()) + .withExternalIdsHolder(new ExternalIdsHolder().withInstanceId(UUID.randomUUID().toString())) + .withMetadata(original.getMetadata()); + + Snapshot snapshot = new Snapshot().withJobExecutionId(UUID.randomUUID().toString()) + .withProcessingStartedDate(new Date()) + .withStatus(Snapshot.Status.PROCESSING_IN_PROGRESS); + + ParsedRecord parsedRecord = new ParsedRecord().withId(matchedId) + .withContent(new JsonObject().put("leader", "01542ccm a2200361 4500") + .put("fields", new JsonArray().add(new JsonObject().put("999", new JsonObject() + .put("subfields", + new JsonArray().add(new JsonObject().put("s", matchedId))) + .put("ind1", "f") + .put("ind2", "f")))).encode()); + Record recordToUpdateGeneration = new Record() + .withId(UUID.randomUUID().toString()) + .withSnapshotId(snapshot.getJobExecutionId()) + .withRecordType(original.getRecordType()) + .withState(State.ACTUAL) + .withGeneration(0) + .withOrder(original.getOrder()) + .withRawRecord(original.getRawRecord()) + .withParsedRecord(parsedRecord) + .withAdditionalInfo(original.getAdditionalInfo()) + .withExternalIdsHolder(new ExternalIdsHolder().withInstanceId(UUID.randomUUID().toString())) + .withMetadata(original.getMetadata()); + Async async = context.async(); + + recordService.saveRecord(record1, TENANT_ID).onComplete(record1Saved -> { + if (record1Saved.failed()) { + context.fail(record1Saved.cause()); + } + context.assertNotNull(record1Saved.result().getRawRecord()); + context.assertNotNull(record1Saved.result().getParsedRecord()); + context.assertEquals(record1Saved.result().getState(), State.ACTUAL); + compareRecords(context, record1, record1Saved.result()); + + SnapshotDaoUtil.save(postgresClientFactory.getQueryExecutor(TENANT_ID), snapshot).onComplete(snapshotSaved -> { + if (snapshotSaved.failed()) { + context.fail(snapshotSaved.cause()); + } + recordService.updateRecordGeneration(matchedId, recordToUpdateGeneration, TENANT_ID).onComplete(recordToUpdateGenerationSaved -> { + context.assertTrue(recordToUpdateGenerationSaved.failed()); + context.assertTrue(recordToUpdateGenerationSaved.cause() instanceof BadRequestException); + async.complete(); + }); + }); + }); + } + + @Test + public void shouldUpdateRecordGeneration(TestContext context) { + String matchedId = UUID.randomUUID().toString(); + Record original = TestMocks.getMarcBibRecord(); + + Record record1 = new Record() + .withId(matchedId) + .withSnapshotId(original.getSnapshotId()) + .withRecordType(original.getRecordType()) + .withState(State.ACTUAL) + .withOrder(original.getOrder()) + .withRawRecord(rawRecord) + .withParsedRecord(marcRecord) + .withAdditionalInfo(original.getAdditionalInfo()) + .withExternalIdsHolder(new ExternalIdsHolder().withInstanceId(UUID.randomUUID().toString())) + .withMetadata(original.getMetadata()); + + Snapshot snapshot = new Snapshot().withJobExecutionId(UUID.randomUUID().toString()) + .withProcessingStartedDate(new Date()) + .withStatus(Snapshot.Status.PROCESSING_IN_PROGRESS); + + ParsedRecord parsedRecord = new ParsedRecord().withId(matchedId) + .withContent(new JsonObject().put("leader", "01542ccm a2200361 4500") + .put("fields", new JsonArray().add(new JsonObject().put("999", new JsonObject() + .put("subfields", + new JsonArray().add(new JsonObject().put("s", matchedId))) + .put("ind1", "f") + .put("ind2", "f")))).encode()); + Record recordToUpdateGeneration = new Record() + .withId(UUID.randomUUID().toString()) + .withSnapshotId(snapshot.getJobExecutionId()) + .withRecordType(original.getRecordType()) + .withState(State.ACTUAL) + .withOrder(original.getOrder()) + .withRawRecord(original.getRawRecord()) + .withParsedRecord(parsedRecord) + .withAdditionalInfo(original.getAdditionalInfo()) + .withExternalIdsHolder(new ExternalIdsHolder().withInstanceId(UUID.randomUUID().toString())) + .withMetadata(original.getMetadata()); + Async async = context.async(); + + recordService.saveRecord(record1, TENANT_ID).onComplete(record1Saved -> { + if (record1Saved.failed()) { + context.fail(record1Saved.cause()); + } + context.assertNotNull(record1Saved.result().getRawRecord()); + context.assertNotNull(record1Saved.result().getParsedRecord()); + context.assertEquals(record1Saved.result().getState(), State.ACTUAL); + compareRecords(context, record1, record1Saved.result()); + + SnapshotDaoUtil.save(postgresClientFactory.getQueryExecutor(TENANT_ID), snapshot).onComplete(snapshotSaved -> { + if (snapshotSaved.failed()) { + context.fail(snapshotSaved.cause()); + } + recordService.updateRecordGeneration(matchedId, recordToUpdateGeneration, TENANT_ID).onComplete(recordToUpdateGenerationSaved -> { + context.assertTrue(recordToUpdateGenerationSaved.succeeded()); + context.assertEquals(recordToUpdateGenerationSaved.result().getMatchedId(), matchedId); + context.assertEquals(recordToUpdateGenerationSaved.result().getGeneration(), 1); + recordDao.getRecordByMatchedId(matchedId, TENANT_ID).onComplete(get -> { + if (get.failed()) { + context.fail(get.cause()); + } + context.assertTrue(get.result().isPresent()); + context.assertEquals(get.result().get().getGeneration(), 1); + context.assertEquals(get.result().get().getMatchedId(), matchedId); + context.assertNotEquals(get.result().get().getId(), matchedId); + context.assertEquals(get.result().get().getState(), State.ACTUAL); + recordDao.getRecordById(matchedId, TENANT_ID).onComplete(getRecord1 -> { + if (getRecord1.failed()) { + context.fail(get.cause()); + } + context.assertTrue(getRecord1.result().isPresent()); + context.assertEquals(getRecord1.result().get().getState(), State.OLD); + async.complete(); + }); + }); + }); + }); + }); + } + @Test public void shouldSaveMarcBibRecordWithMatchedIdFromRecordId(TestContext context) { Record original = TestMocks.getMarcBibRecord(); diff --git a/ramls/source-record-storage-records.raml b/ramls/source-record-storage-records.raml index 78d10b78a..de6517f41 100644 --- a/ramls/source-record-storage-records.raml +++ b/ramls/source-record-storage-records.raml @@ -154,4 +154,19 @@ resourceTypes: body: text/plain: example: "Internal server error" - + /generation: + displayName: Generation + is: [validate] + put: + description: Updates a specific Record with incremented generation and state ACTUAL by matched id + body: + application/json: + type: record + example: + strict: false + value: !include raml-storage/examples/mod-source-record-storage/record.sample + responses: + 200: + body: + application/json: + type: record