From 185835082142b61c2110af06f3c25f058d93f44b Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 10 Jun 2024 16:00:30 +0200 Subject: [PATCH 001/150] [kbss-cvut/termit-ui#449] Add Excel template file for importing vocabularies. --- src/main/resources/template/termit-import.xlsx | Bin 0 -> 67470 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/main/resources/template/termit-import.xlsx diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..94c7b2ae750946fd211c7bdf78c795896b74dd32 GIT binary patch literal 67470 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 literal 0 HcmV?d00001 From 3e2b47f76a17e08a3ac36cf04e6c85fc992c7afe Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 18 Jun 2024 13:28:55 +0200 Subject: [PATCH 002/150] [kbss-cvut/termit-ui#449] Allow downloading Excel import template file. --- .../termit/rest/VocabularyController.java | 22 ++++++++++-- .../service/business/VocabularyService.java | 34 +++++++++++++++++-- .../util/TypeAwareFileSystemResource.java | 7 ++++ .../kbss/termit/util/TypeAwareResource.java | 6 ++-- .../termit/rest/VocabularyControllerTest.java | 16 ++++++++- .../repository/VocabularyServiceTest.java | 33 ++++++++++++++++-- 6 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index eeecfee63..a8b442659 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -34,6 +34,7 @@ import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants.QueryParams; +import cz.cvut.kbss.termit.util.TypeAwareResource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -43,6 +44,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -60,9 +62,9 @@ /** * Vocabulary management REST API. - * - * Note that most endpoints are now secured only by requiring the user to be authenticated, authorization is done - * on service level based on ACL. + *

+ * Note that most endpoints are now secured only by requiring the user to be authenticated, authorization is done on + * service level based on ACL. */ @Tag(name = "Vocabularies", description = "Vocabulary management API") @RestController @@ -183,6 +185,20 @@ public ResponseEntity createVocabulary( return ResponseEntity.created(locationWithout(generateLocation(vocabulary.getUri()), "/import")).build(); } + @Operation(description = "Gets a template Excel file that can be used to import terms into TermIt") + @ApiResponse(responseCode = "200", description = "Template Excel file is returned as attachment") + @GetMapping("/import/template") + @PreAuthorize("permitAll()") + public ResponseEntity getExcelTemplateFile() { + final TypeAwareResource template = vocabularyService.getExcelTemplateFile(); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType( + template.getMediaType().orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE))) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + template.getFilename() + "\"") + .body(template); + } + URI locationWithout(URI location, String toRemove) { return URI.create(location.toString().replace(toRemove, "")); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index ac55ce39a..4ea780bf7 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -25,6 +25,7 @@ import cz.cvut.kbss.termit.dto.listing.VocabularyDto; import cz.cvut.kbss.termit.event.VocabularyCreatedEvent; import cz.cvut.kbss.termit.exception.NotFoundException; +import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.acl.AccessControlList; import cz.cvut.kbss.termit.model.acl.AccessControlRecord; @@ -35,10 +36,14 @@ import cz.cvut.kbss.termit.persistence.snapshot.SnapshotCreator; import cz.cvut.kbss.termit.service.business.async.AsyncTermService; import cz.cvut.kbss.termit.service.changetracking.ChangeRecordProvider; +import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; +import cz.cvut.kbss.termit.service.export.ExportFormat; +import cz.cvut.kbss.termit.service.export.ExportType; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; +import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -53,9 +58,18 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.io.File; import java.net.URI; +import java.net.URISyntaxException; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; /** * Business logic concerning vocabularies. @@ -130,7 +144,8 @@ public Optional find(URI id) { @PostAuthorize("@vocabularyAuthorizationService.canRead(returnObject)") public Vocabulary findRequired(URI id) { // Enhance vocabulary data with info on current user's access level - final cz.cvut.kbss.termit.dto.VocabularyDto dto = new cz.cvut.kbss.termit.dto.VocabularyDto(repositoryService.findRequired(id)); + final cz.cvut.kbss.termit.dto.VocabularyDto dto = new cz.cvut.kbss.termit.dto.VocabularyDto( + repositoryService.findRequired(id)); dto.setAccessLevel(getAccessLevel(dto)); return dto; } @@ -227,6 +242,21 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { return repositoryService.importVocabulary(vocabularyIri, file); } + /** + * Gets an Excel template file that can be used to import terms into TermIt. + * + * @return Template file as a resource + */ + public TypeAwareResource getExcelTemplateFile() { + try { + assert getClass().getClassLoader().getResource("template/termit-import.xlsx") != null; + final File template = new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); + return new TypeAwareFileSystemResource(template, ExportFormat.EXCEL.getMediaType()); + } catch (URISyntaxException e) { + throw new TermItException("Fatal error, unable to load Excel template file.", e); + } + } + @Override public List getChanges(Vocabulary asset) { return changeRecordService.getChanges(asset); diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java b/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java index 01913f836..fa6898044 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java @@ -41,6 +41,13 @@ public Optional getMediaType() { return Optional.ofNullable(mediaType); } + @Override + public Optional getFileExtension() { + assert getFilename() != null; + return getFilename().contains(".") ? Optional.of(getFilename().substring(getFilename().lastIndexOf("."))) : + Optional.empty(); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java index d52ea7be0..211b199e1 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java +++ b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java @@ -24,8 +24,8 @@ /** * An IO resource aware of its media type. *

- * Allows to get MIME type of the resource and the associated file extension. However, both methods return {@link Optional} - * to accommodate resources which may not support this feature. + * Allows to get MIME type of the resource and the associated file extension. However, both methods return + * {@link Optional} to accommodate resources which may not support this feature. */ public interface TypeAwareResource extends Resource { @@ -40,6 +40,8 @@ default Optional getMediaType() { /** * Gets file extension of this resource (if supported). + *

+ * The file extension is including the dot, so, for example, {@literal .csv}. * * @return File extension associated with this type of resource wrapped in {@code Optional} */ diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index 2dbfcb2de..cea6efd23 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -38,6 +38,7 @@ import cz.cvut.kbss.termit.rest.handler.ErrorInfo; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.VocabularyService; +import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; @@ -56,6 +57,7 @@ import org.springframework.test.web.servlet.MvcResult; import org.topbraid.shacl.vocabulary.SH; +import java.io.File; import java.math.BigInteger; import java.net.URI; import java.time.Instant; @@ -615,7 +617,7 @@ void updateAccessControlLevelThrowsBadRequestForRecordUriNotMatchingPath() throw record.setHolder(Generator.generateUserWithId()); mockMvc.perform(put(PATH + "/" + FRAGMENT + "/acl/records/" + Generator.randomInt()) - .content(toJson(record)).contentType(MediaType.APPLICATION_JSON)) + .content(toJson(record)).contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); verify(serviceMock, never()).updateAccessControlLevel(any(Vocabulary.class), any(AccessControlRecord.class)); } @@ -630,4 +632,16 @@ void getAccessLevelRetrievesAccessLevelToSpecifiedVocabulary() throws Exception assertEquals(AccessLevel.SECURITY, result); verify(serviceMock).getAccessLevel(vocabulary); } + + @Test + void getExcelTemplateFileReturnsExcelTemplateFileRetrievedFromServiceAsAttachment() throws Exception { + when(serviceMock.getExcelTemplateFile()).thenReturn(new TypeAwareFileSystemResource( + new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()), + Constants.MediaType.EXCEL)); + + final MvcResult mvcResult = mockMvc.perform(get(PATH + "/import/template")).andReturn(); + assertThat(mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION), containsString("attachment")); + assertThat(mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION), + containsString("filename=\"termit-import.xlsx\"")); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java index e9f76513e..caa02710d 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java @@ -37,7 +37,9 @@ import cz.cvut.kbss.termit.service.business.AccessControlListService; import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.service.business.async.AsyncTermService; +import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; +import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -53,12 +55,26 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; +import java.io.File; import java.nio.charset.StandardCharsets; -import java.util.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; import static cz.cvut.kbss.termit.environment.Environment.termsToDtos; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +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.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class VocabularyServiceTest { @@ -356,4 +372,15 @@ void importNewVocabularyPublishesVocabularyCreatedEvent() { assertInstanceOf(VocabularyCreatedEvent.class, captor.getValue()); assertEquals(persisted, captor.getValue().getSource()); } + + @Test + void getExcelTemplateFileReturnsResourceRepresentingExcelTemplateFile() throws Exception { + final TypeAwareResource result = sut.getExcelTemplateFile(); + assertTrue(result.getFileExtension().isPresent()); + assertEquals(ExportFormat.EXCEL.getFileExtension(), result.getFileExtension().get()); + assertTrue(result.getMediaType().isPresent()); + assertEquals(ExportFormat.EXCEL.getMediaType(), result.getMediaType().get()); + final File expectedFile = new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); + assertEquals(expectedFile, result.getFile()); + } } From aba8fe06adbb59972a471e15f01bbd4e3262d5e1 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 18 Jun 2024 14:32:46 +0200 Subject: [PATCH 003/150] [kbss-cvut/termit-ui#449] Add updated Excel template with value lists for term types and states. --- .../resources/template/termit-import.xlsx | Bin 67470 -> 66960 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx index 94c7b2ae750946fd211c7bdf78c795896b74dd32..fb1fab8261127be534fc916b5fcd71ae07f0c52f 100644 GIT binary patch literal 66960 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 From c402ab112cbeee11651202ef92eaa430d432b5c0 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 18 Jun 2024 14:51:45 +0200 Subject: [PATCH 004/150] [kbss-cvut/termit-ui#449] Make Excel import template file configurable. Mainly to allow aligning value lists in the Excel with value taxonomy languages (term types, states) used by TermIt instance. --- .../service/business/VocabularyService.java | 19 +++--- .../cvut/kbss/termit/util/Configuration.java | 60 +++++++++++++++---- .../repository/VocabularyServiceTest.java | 2 + 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 4ea780bf7..43b92df8d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -38,11 +38,11 @@ import cz.cvut.kbss.termit.service.changetracking.ChangeRecordProvider; import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.service.export.ExportFormat; -import cz.cvut.kbss.termit.service.export.ExportType; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; +import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -248,13 +248,16 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { * @return Template file as a resource */ public TypeAwareResource getExcelTemplateFile() { - try { - assert getClass().getClassLoader().getResource("template/termit-import.xlsx") != null; - final File template = new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); - return new TypeAwareFileSystemResource(template, ExportFormat.EXCEL.getMediaType()); - } catch (URISyntaxException e) { - throw new TermItException("Fatal error, unable to load Excel template file.", e); - } + final Configuration config = context.getBean(Configuration.class); + final File templateFile = config.getTemplate().getExcelImport().map(File::new).orElseGet(() -> { + try { + assert getClass().getClassLoader().getResource("template/termit-import.xlsx") != null; + return new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); + } catch (URISyntaxException e) { + throw new TermItException("Fatal error, unable to load Excel template file.", e); + } + }); + return new TypeAwareFileSystemResource(templateFile, ExportFormat.EXCEL.getMediaType()); } @Override diff --git a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java index beeb1a410..ff02a6953 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java @@ -33,7 +33,8 @@ * The runtime configuration consists of predefined default values and configuration loaded from config files on * classpath. Values from config files supersede the default values. *

- * The configuration can be also set via OS + * The configuration can be also set via OS * environment variables. These override any statically configured values. */ @ConfigurationProperties("termit") @@ -89,6 +90,8 @@ public class Configuration { private Security security = new Security(); @Valid private Language language = new Language(); + @Valid + private Template template = new Template(); public String getUrl() { return url; @@ -250,6 +253,14 @@ public void setLanguage(Language language) { this.language = language; } + public Template getTemplate() { + return template; + } + + public void setTemplate(Template template) { + this.template = template; + } + @Validated public static class Persistence { /** @@ -407,10 +418,11 @@ public static class Namespace { * Since Term identifier is given by the identifier of the Vocabulary it belongs to and its own normalized * label, this separator is used to (optionally) configure the Term identifier namespace. *

- * For example, if we have a Vocabulary with IRI {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan} - * and a Term with normalized label {@code inhabited-area}, the resulting IRI will be {@code - * http://www.example.org/ontologies/vocabularies/metropolitan-plan/SEPARATOR/inhabited-area}, where 'SEPARATOR' - * is the value of this configuration parameter. + * For example, if we have a Vocabulary with IRI + * {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan} and a Term with normalized label + * {@code inhabited-area}, the resulting IRI will be + * {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan/SEPARATOR/inhabited-area}, where + * 'SEPARATOR' is the value of this configuration parameter. */ @Valid private NamespaceDetail term = new NamespaceDetail(); @@ -420,9 +432,10 @@ public static class Namespace { * Since File identifier is given by the identifier of the Document it belongs to and its own normalized label, * this separator is used to (optionally) configure the File identifier namespace. *

- * For example, if we have a Document with IRI {@code http://www.example.org/ontologies/resources/metropolitan-plan/document} - * and a File with normalized label {@code main-file}, the resulting IRI will be {@code - * http://www.example.org/ontologies/resources/metropolitan-plan/document/SEPARATOR/main-file}, where + * For example, if we have a Document with IRI + * {@code http://www.example.org/ontologies/resources/metropolitan-plan/document} and a File with normalized + * label {@code main-file}, the resulting IRI will be + * {@code http://www.example.org/ontologies/resources/metropolitan-plan/document/SEPARATOR/main-file}, where * 'SEPARATOR' is the value of this configuration parameter. */ @Valid @@ -431,8 +444,9 @@ public static class Namespace { /** * Separator of snapshot timestamp and original asset identifier. *

- * For example, if we have a Vocabulary with IRI {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan} - * and the snapshot separator is configured to {@code version}, a snapshot IRI will look something like + * For example, if we have a Vocabulary with IRI + * {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan} and the snapshot separator is + * configured to {@code version}, a snapshot IRI will look something like * {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan/version/20220530T202317Z}. */ @Valid @@ -834,8 +848,8 @@ public static class Language { private LanguageSource types = new LanguageSource(); /** - * Path to a file containing definition of the language of states terms can be in. The file must be in - * Turtle format. The term definitions must use SKOS terminology for attributes (prefLabel, scopeNote and + * Path to a file containing definition of the language of states terms can be in. The file must be in Turtle + * format. The term definitions must use SKOS terminology for attributes (prefLabel, scopeNote and * broader/narrower). */ @Valid @@ -870,4 +884,26 @@ public void setSource(String source) { } } } + + @Validated + public static class Template { + + /** + * Template file for Excel import. + *

+ * The purpose of configuring this file is mainly to have the value lists for term types and states in the + * template aligned with the corresponding languages used by TermIt. + *

+ * Empty value means the built-in template file should be used. + */ + private Optional excelImport = Optional.empty(); + + public Optional getExcelImport() { + return excelImport; + } + + public void setExcelImport(Optional excelImport) { + this.excelImport = excelImport; + } + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java index caa02710d..a66829ab1 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java @@ -39,6 +39,7 @@ import cz.cvut.kbss.termit.service.business.async.AsyncTermService; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; +import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; @@ -375,6 +376,7 @@ void importNewVocabularyPublishesVocabularyCreatedEvent() { @Test void getExcelTemplateFileReturnsResourceRepresentingExcelTemplateFile() throws Exception { + when(appContext.getBean(Configuration.class)).thenReturn(new Configuration()); final TypeAwareResource result = sut.getExcelTemplateFile(); assertTrue(result.getFileExtension().isPresent()); assertEquals(ExportFormat.EXCEL.getFileExtension(), result.getFileExtension().get()); From d36d00e9c8c765f2729a5cd6641d09fe72abe1cb Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Wed, 19 Jun 2024 08:02:40 +0200 Subject: [PATCH 005/150] [kbss-cvut/termit-ui#449] Refactor SKOSImporter - provide a common VocabularyImporter interface. --- .../persistence/dao/skos/SKOSImporter.java | 50 ++---- .../service/importer/VocabularyImporter.java | 51 ++++++ .../VocabularyRepositoryService.java | 9 +- .../dao/skos/SKOSImporterTest.java | 167 ++++++++++-------- ...VocabularyRepositoryServiceImportTest.java | 14 +- 5 files changed, 173 insertions(+), 118 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporter.java diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java index 663822ee7..514f99e05 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java @@ -25,6 +25,7 @@ import cz.cvut.kbss.termit.model.Glossary; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; import org.eclipse.rdf4j.model.IRI; @@ -71,7 +72,7 @@ */ @Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) -public class SKOSImporter { +public class SKOSImporter implements VocabularyImporter { private static final Logger LOG = LoggerFactory.getLogger(SKOSImporter.class); @@ -102,49 +103,17 @@ public SKOSImporter(Configuration config, VocabularyDao vocabularyDao, EntityMan this.em = em; } - /** - * Imports a new vocabulary from the specified streams representing the vocabulary in SKOS format. - * - * @param rename Whether to change vocabulary, glossary and term IRIs in case of a conflict with existing - * data - * @param mediaType Input data media type - * @param persist Consumer of the imported vocabulary, used to save the imported data - * @param inputStreams Streams containing the imported SKOS data - * @return The imported vocabulary - * @throws VocabularyExistsException If a vocabulary/glossary with the same identifier already exists and - * {@code rename} is set to {@code false} - * @throws IllegalArgumentException Indicates invalid input data, e.g., no input streams, missing language tags - * etc. - */ - public Vocabulary importVocabulary(boolean rename, String mediaType, final Consumer persist, - final InputStream... inputStreams) { - return importVocabulary(rename, null, mediaType, persist, inputStreams); - } - - /** - * Imports a SKOS vocabulary from the specified streams, possibly replacing an existing one. - *

- * If the specified {@code vocabularyIri} identifies an existing vocabulary, its content is replaced with the - * imported data. - * - * @param vocabularyIri Target vocabulary identifier - * @param mediaType Input data media type - * @param persist Consumer of the imported vocabulary, used to save the imported data - * @param inputStreams Streams containing the imported SKOS data - * @return The imported vocabulary - * @throws IllegalArgumentException Indicates invalid input data, e.g., no input streams, missing language tags - * etc. - */ - public Vocabulary importVocabulary(URI vocabularyIri, String mediaType, final Consumer persist, - final InputStream... inputStreams) { - Objects.requireNonNull(vocabularyIri); - return importVocabulary(false, vocabularyIri, mediaType, persist, inputStreams); + @Override + public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) { + Objects.requireNonNull(config); + Objects.requireNonNull(data); + return importVocabulary(config.allowReIdentify(), config.vocabularyIri(), data.mediaType(), config.prePersist(), data.data()); } private Vocabulary importVocabulary(final boolean rename, final URI vocabularyIri, final String mediaType, - final Consumer persist, + final Consumer prePersist, final InputStream... inputStreams) { if (inputStreams.length == 0) { throw new IllegalArgumentException("No input provided for importing vocabulary."); @@ -184,7 +153,8 @@ private Vocabulary importVocabulary(final boolean rename, em.flush(); em.clear(); - persist.accept(vocabulary); + prePersist.accept(vocabulary); + vocabularyDao.persist(vocabulary); addDataIntoRepository(vocabulary.getUri()); LOG.debug("Vocabulary import successfully finished."); return vocabulary; diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporter.java new file mode 100644 index 000000000..81b2ab820 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporter.java @@ -0,0 +1,51 @@ +package cz.cvut.kbss.termit.service.importer; + +import cz.cvut.kbss.termit.exception.importing.VocabularyExistsException; +import cz.cvut.kbss.termit.model.Vocabulary; + +import java.io.InputStream; +import java.net.URI; +import java.util.function.Consumer; + +/** + * Supports importing vocabularies. + */ +public interface VocabularyImporter { + + /** + * Imports vocabulary from the specified data. + *

+ * Import configuration allows to specify handling of existing vocabulary data or target vocabulary identifier. + * + * @param config Import configuration + * @param data Data to import + * @return Imported vocabulary + * @throws VocabularyExistsException If a vocabulary/glossary with the same identifier already exists and + * {@code config} does not allow renaming or overwriting + * @throws IllegalArgumentException Indicates invalid input data, e.g., no input streams, missing language tags + * etc. + */ + Vocabulary importVocabulary(ImportConfiguration config, ImportInput data); + + /** + * Vocabulary import configuration. + * + * @param allowReIdentify Whether to allow modifying identifiers when repository already contains data with matching + * identifiers + * @param vocabularyIri Identifier of the target vocabulary, optional. If specified, any pre-existing data are + * overwritten + * @param prePersist Procedure to call before persisting the resulting vocabulary + */ + record ImportConfiguration(boolean allowReIdentify, URI vocabularyIri, + Consumer prePersist) { + } + + /** + * Data to import. + * + * @param mediaType Media type of the imported data + * @param data Streams containing the data + */ + record ImportInput(String mediaType, InputStream... data) { + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index cc71adacb..d15dd0786 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -35,6 +35,7 @@ import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.MessageFormatter; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; @@ -230,7 +231,9 @@ public Vocabulary importVocabulary(boolean rename, MultipartFile file) { Objects.requireNonNull(file); try { String contentType = resolveContentType(file); - return getSKOSImporter().importVocabulary(rename, contentType, this::persist, file.getInputStream()); + return getSKOSImporter().importVocabulary( + new VocabularyImporter.ImportConfiguration(rename, null, this::initDocument), + new VocabularyImporter.ImportInput(contentType, file.getInputStream())); } catch (VocabularyImportException e) { throw e; } catch (Exception e) { @@ -251,7 +254,9 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { Objects.requireNonNull(file); try { String contentType = resolveContentType(file); - return getSKOSImporter().importVocabulary(vocabularyIri, contentType, this::persist, file.getInputStream()); + return getSKOSImporter().importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabularyIri, this::initDocument), + new VocabularyImporter.ImportInput(contentType, file.getInputStream())); } catch (VocabularyImportException e) { throw e; } catch (Exception e) { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java index 85acf422c..88c9e20be 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java @@ -30,6 +30,7 @@ import cz.cvut.kbss.termit.persistence.context.DescriptorFactory; import cz.cvut.kbss.termit.persistence.dao.BaseDaoTestRunner; import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Vocabulary; import org.eclipse.rdf4j.model.Resource; @@ -48,6 +49,7 @@ import org.springframework.test.annotation.DirtiesContext; import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -110,8 +112,9 @@ void setUp() { void importVocabularyImportsGlossaryFromSpecifiedStream() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final Repository repo = em.unwrap(Repository.class); @@ -131,13 +134,15 @@ void importVocabularyImportsGlossaryFromSpecifiedStream() { void importVocabularyRenamesVocabularyIriWhenAlreadyPresent() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final Repository repo = em.unwrap(Repository.class); @@ -153,13 +158,15 @@ void importVocabularyRenamesVocabularyIriWhenAlreadyPresent() { void importVocabularyRenamesTermIriUponRenamingVocabularyIri() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final List vocabularies = vocabularyDao.findAll(); @@ -180,7 +187,10 @@ void importThrowsIllegalArgumentExceptionWhenNoStreamIsProvided() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); assertThrows(IllegalArgumentException.class, - () -> sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister)); + () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + (InputStream) null))); }); } @@ -189,9 +199,11 @@ void importThrowsIllegalArgumentExceptionWhenVocabularyIriIsGivenButDoesNotMatch transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); assertThrows(IllegalArgumentException.class, () -> - sut.importVocabulary(URI.create(VOCABULARY_IRI_S + "-1"), Constants.MediaType.TURTLE, - persister, Environment.loadFile("data/test-glossary.ttl")) - ); + sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, URI.create(VOCABULARY_IRI_S + "-1"), + persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl")))); }); } @@ -207,8 +219,9 @@ void importInsertsImportedDataIntoContextBasedOnOntologyIdentifier() { }); transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final Repository repo = em.unwrap(Repository.class); @@ -230,9 +243,10 @@ void importInsertsImportedDataIntoContextBasedOnOntologyIdentifier() { void importResolvesVocabularyIriForContextWhenMultipleStreamsWithGlossaryAndVocabularyAreProvided() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { final Repository repo = em.unwrap(Repository.class); @@ -264,13 +278,15 @@ void importThrowsIllegalArgumentExceptionWhenTargetContextCannotBeDeterminedFrom transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); final VocabularyImportException ex = assertThrows(VocabularyImportException.class, - () -> sut - .importVocabulary(VOCABULARY_IRI, - Constants.MediaType.TURTLE, - persister, - new ByteArrayInputStream( - input.getBytes( - StandardCharsets.UTF_8)))); + () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, + VOCABULARY_IRI, + persister), + new VocabularyImporter.ImportInput( + Constants.MediaType.TURTLE, + new ByteArrayInputStream( + input.getBytes( + StandardCharsets.UTF_8))))); assertThat(ex.getMessage(), containsString("No unique skos:ConceptScheme found in the provided data.")); }); } @@ -279,9 +295,10 @@ void importThrowsIllegalArgumentExceptionWhenTargetContextCannotBeDeterminedFrom void importThrowsUnsupportedImportMediaTypeExceptionForUnsupportedDataType() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - assertThrows(UnsupportedImportMediaTypeException.class, () -> sut - .importVocabulary(VOCABULARY_IRI, Constants.MediaType.EXCEL, persister, - Environment.loadFile("data/test-glossary.ttl"))); + assertThrows(UnsupportedImportMediaTypeException.class, () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile("data/test-glossary.ttl")))); }); } @@ -289,9 +306,10 @@ void importThrowsUnsupportedImportMediaTypeExceptionForUnsupportedDataType() { void importReturnsVocabularyInstanceConstructedFromImportedData() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - final cz.cvut.kbss.termit.model.Vocabulary result = sut - .importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + final cz.cvut.kbss.termit.model.Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); assertNotNull(result); assertEquals(VOCABULARY_IRI, result.getUri()); assertEquals("Vocabulary of system TermIt - glossary", result.getPrimaryLabel()); @@ -302,14 +320,15 @@ void importReturnsVocabularyInstanceConstructedFromImportedData() { void importGeneratesRelationshipsBetweenTermsAndGlossary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { try (final RepositoryConnection conn = em.unwrap(Repository.class).getConnection()) { final List terms = conn.getStatements(null, RDF.TYPE, SKOS.CONCEPT).stream() - .map(Statement::getSubject).toList(); + .map(Statement::getSubject).toList(); assertFalse(terms.isEmpty()); terms.forEach(t -> assertTrue(conn.getStatements(t, SKOS.IN_SCHEME, vf.createIRI(GLOSSARY_IRI)).hasNext())); @@ -321,14 +340,15 @@ void importGeneratesRelationshipsBetweenTermsAndGlossary() { void importGeneratesTopConceptAssertions() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { try (final RepositoryConnection conn = em.unwrap(Repository.class).getConnection()) { final List terms = conn.getStatements(null, SKOS.HAS_TOP_CONCEPT, null).stream() - .map(Statement::getObject).collect(Collectors.toList()); + .map(Statement::getObject).collect(Collectors.toList()); assertEquals(1, terms.size()); assertThat(terms, hasItem(vf.createIRI(Vocabulary.s_c_uzivatel_termitu))); } @@ -339,14 +359,15 @@ void importGeneratesTopConceptAssertions() { void importGeneratesTopConceptAssertionsForGlossaryUsingNarrowerProperty() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary-narrower.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, Environment.loadFile( + "data/test-glossary-narrower.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { try (final RepositoryConnection conn = em.unwrap(Repository.class).getConnection()) { final List terms = conn.getStatements(null, SKOS.HAS_TOP_CONCEPT, null).stream() - .map(Statement::getObject).collect(Collectors.toList()); + .map(Statement::getObject).collect(Collectors.toList()); assertEquals(1, terms.size()); assertThat(terms, hasItem(vf.createIRI(Vocabulary.s_c_uzivatel_termitu))); } @@ -358,10 +379,12 @@ void importFailsIfAnEmptyLanguageTagIsProvidedForMultilingualProperties() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); assertThrows(IllegalArgumentException.class, () -> - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary-narrower.ttl"), - Environment.loadFile( - "data/test-glossary-with-definition-with-empty-language-tag.ttl"))); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile( + "data/test-glossary.ttl"), + Environment.loadFile( + "data/test-glossary-with-definition-with-empty-language-tag.ttl")))); }); } @@ -369,11 +392,11 @@ void importFailsIfAnEmptyLanguageTagIsProvidedForMultilingualProperties() { void importThrowsVocabularyExistsExceptionWhenGlossaryExistsForNewVocabulary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - assertThrows(VocabularyExistsException.class, () -> sut.importVocabulary(false, - Constants.MediaType.TURTLE, - persister, - Environment.loadFile( - "data/test-glossary.ttl"))); + assertThrows(VocabularyExistsException.class, + () -> sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile( + "data/test-glossary.ttl")))); }); } @@ -381,8 +404,9 @@ void importThrowsVocabularyExistsExceptionWhenGlossaryExistsForNewVocabulary() { void importOverridesExistingGlossaryWhenImportingExistingVocabulary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); final Glossary result = em.find(Glossary.class, URI.create(GLOSSARY_IRI)); @@ -401,8 +425,9 @@ void importConnectsExistingDocumentToReimportedVocabulary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); final cz.cvut.kbss.termit.model.Vocabulary result = findVocabulary(); assertNotNull(result); @@ -421,14 +446,15 @@ private cz.cvut.kbss.termit.model.Vocabulary findVocabulary() { void importSkipsAssertedTopConceptOfStatements() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary-with-topconceptof.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, Environment.loadFile( + "data/test-glossary-with-topconceptof.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { try (final RepositoryConnection conn = em.unwrap(Repository.class).getConnection()) { final List terms = conn.getStatements(null, SKOS.HAS_TOP_CONCEPT, null).stream() - .map(Statement::getObject).collect(Collectors.toList()); + .map(Statement::getObject).collect(Collectors.toList()); assertEquals(1, terms.size()); assertThat(terms, hasItem(vf.createIRI(Vocabulary.s_c_uzivatel_termitu))); assertFalse(conn.hasStatement(vf.createIRI(Vocabulary.s_c_uzivatel_termitu), SKOS.TOP_CONCEPT_OF, null, @@ -441,9 +467,10 @@ void importSkipsAssertedTopConceptOfStatements() { void importMovesDescriptionFromGlossaryToVocabulary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); final Optional result = vocabularyDao.find(VOCABULARY_IRI); @@ -461,8 +488,9 @@ void importConnectsExistingAccessControlListToImportedVocabulary() { }); transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); final cz.cvut.kbss.termit.model.Vocabulary result = findVocabulary(); @@ -474,9 +502,10 @@ void importConnectsExistingAccessControlListToImportedVocabulary() { void importImportsVocabularyLabelAndDescriptionInAllDeclaredLanguages() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); final Set languages = Set.of("en", "cs"); diff --git a/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java b/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java index ba11dc259..b5fe33c37 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java @@ -21,6 +21,7 @@ import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; @@ -36,12 +37,10 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.io.InputStream; -import java.net.URI; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -71,11 +70,12 @@ void passesInputStreamFromProvidedInputFileToImporter() throws IOException { Constants.MediaType.TURTLE, Environment.loadFile("data/test-vocabulary.ttl")); final Vocabulary vocabulary = Generator.generateVocabularyWithId(); - when(importer.importVocabulary(any(URI.class), any(), any(), any())).thenReturn(vocabulary); + when(importer.importVocabulary(any(VocabularyImporter.ImportConfiguration.class), + any(VocabularyImporter.ImportInput.class))).thenReturn(vocabulary); final Vocabulary result = sut.importVocabulary(vocabulary.getUri(), input); - final ArgumentCaptor captor = ArgumentCaptor.forClass(InputStream.class); - verify(importer).importVocabulary(eq(vocabulary.getUri()), eq(Constants.MediaType.TURTLE), any(), - captor.capture()); + final ArgumentCaptor captor = ArgumentCaptor.forClass( + VocabularyImporter.ImportInput.class); + verify(importer).importVocabulary(any(VocabularyImporter.ImportConfiguration.class), captor.capture()); assertNotNull(captor.getValue()); assertEquals(vocabulary, result); } From 6020675f0bf3944cda20061e0ee65981564556c5 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Wed, 19 Jun 2024 18:23:52 +0200 Subject: [PATCH 006/150] [kbss-cvut/termit-ui#449] Resolve vocabulary importer based on provided media type. --- .../persistence/dao/skos/SKOSImporter.java | 11 +++ .../service/importer/ExcelImporter.java | 34 +++++++ .../service/importer/VocabularyImporter.java | 7 +- .../service/importer/VocabularyImporters.java | 41 +++++++++ .../VocabularyRepositoryService.java | 23 ++--- .../cz/cvut/kbss/termit/util/Constants.java | 3 + .../dao/skos/SKOSImporterTest.java | 13 +++ ...VocabularyRepositoryServiceImportTest.java | 18 +--- .../service/importer/ExcelImporterTest.java | 22 +++++ .../importer/VocabularyImportersTest.java | 89 +++++++++++++++++++ 10 files changed, 226 insertions(+), 35 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java create mode 100644 src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java create mode 100644 src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java index 514f99e05..5dd87cfef 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java @@ -28,6 +28,7 @@ import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; +import jakarta.validation.constraints.NotNull; import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.Literal; import org.eclipse.rdf4j.model.Model; @@ -361,4 +362,14 @@ private void handleGlossaryStringProperty(IRI property, Consumer prePersist) { + @NotNull Consumer prePersist) { } /** @@ -46,6 +47,6 @@ record ImportConfiguration(boolean allowReIdentify, URI vocabularyIri, * @param mediaType Media type of the imported data * @param data Streams containing the data */ - record ImportInput(String mediaType, InputStream... data) { + record ImportInput(@NotNull String mediaType, InputStream... data) { } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java new file mode 100644 index 000000000..f10a4bc3a --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java @@ -0,0 +1,41 @@ +package cz.cvut.kbss.termit.service.importer; + +import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import org.springframework.context.ApplicationContext; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +/** + * Ensures correct importer is invoked for provided media types. + */ +@Component +public class VocabularyImporters implements VocabularyImporter { + + private final ApplicationContext appContext; + + public VocabularyImporters(ApplicationContext appContext) { + this.appContext = appContext; + } + + @Override + public Vocabulary importVocabulary(@NonNull ImportConfiguration config, @NonNull ImportInput data) { + if (SKOSImporter.supportsMediaType(data.mediaType())) { + return getSkosImporter().importVocabulary(config, data); + } + if (ExcelImporter.supportsMediaType(data.mediaType())) { + return getExcelImporter().importVocabulary(config, data); + } + throw new UnsupportedImportMediaTypeException( + "Unsupported media type '" + data.mediaType() + "' for vocabulary import."); + } + + private VocabularyImporter getSkosImporter() { + return appContext.getBean(SKOSImporter.class); + } + + private VocabularyImporter getExcelImporter() { + return appContext.getBean(ExcelImporter.class); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index d15dd0786..18dc4ccae 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -32,10 +32,10 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.persistence.dao.BaseAssetDao; import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; -import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.MessageFormatter; import cz.cvut.kbss.termit.service.importer.VocabularyImporter; +import cz.cvut.kbss.termit.service.importer.VocabularyImporters; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; @@ -50,7 +50,6 @@ import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; -import org.springframework.context.ApplicationContext; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -78,31 +77,23 @@ public class VocabularyRepositoryService extends BaseAssetRepositoryService getPrimaryDao() { return vocabularyDao; @@ -231,7 +222,7 @@ public Vocabulary importVocabulary(boolean rename, MultipartFile file) { Objects.requireNonNull(file); try { String contentType = resolveContentType(file); - return getSKOSImporter().importVocabulary( + return importers.importVocabulary( new VocabularyImporter.ImportConfiguration(rename, null, this::initDocument), new VocabularyImporter.ImportInput(contentType, file.getInputStream())); } catch (VocabularyImportException e) { @@ -254,7 +245,7 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { Objects.requireNonNull(file); try { String contentType = resolveContentType(file); - return getSKOSImporter().importVocabulary( + return importers.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabularyIri, this::initDocument), new VocabularyImporter.ImportInput(contentType, file.getInputStream())); } catch (VocabularyImportException e) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index c33e0969c..e493813bc 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -193,6 +193,9 @@ private RDFa() { * Additional media types not covered by {@link org.springframework.http.MediaType}. */ public static final class MediaType { + /** + * Media type for .xlsx + */ public static final String EXCEL = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; public static final String TURTLE = "text/turtle"; public static final String RDF_XML = "application/rdf+xml"; diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java index 88c9e20be..b4ee9f593 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java @@ -44,6 +44,8 @@ import org.eclipse.rdf4j.repository.RepositoryConnection; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.test.annotation.DirtiesContext; @@ -516,4 +518,15 @@ void importImportsVocabularyLabelAndDescriptionInAllDeclaredLanguages() { assertThat(result.get().getDescription().get(lang), not(emptyOrNullString())); }); } + + @ParameterizedTest + @CsvSource({Constants.MediaType.TURTLE, Constants.MediaType.RDF_XML, "application/n-triples"}) + void supportsMediaTypeReturnsTrueForSupportedRDFBasedMediaTypes(String mediaType) { + assertTrue(SKOSImporter.supportsMediaType(mediaType)); + } + + @Test + void supportsMediaTypeReturnsFalseForUnsupportedMediaType() { + assertFalse(SKOSImporter.supportsMediaType(Constants.MediaType.EXCEL)); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java b/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java index b5fe33c37..276a146a9 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java @@ -20,19 +20,16 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; import cz.cvut.kbss.termit.service.importer.VocabularyImporter; +import cz.cvut.kbss.termit.service.importer.VocabularyImporters; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; -import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationContext; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; @@ -48,22 +45,11 @@ class VocabularyRepositoryServiceImportTest { @Mock - private SKOSImporter importer; - - @Mock - private ApplicationContext context; - - @Mock - private Configuration configuration; + private VocabularyImporters importer; @InjectMocks private VocabularyRepositoryService sut; - @BeforeEach - void setUp() { - when(context.getBean(SKOSImporter.class)).thenReturn(importer); - } - @Test void passesInputStreamFromProvidedInputFileToImporter() throws IOException { final MultipartFile input = new MockMultipartFile("vocabulary.ttl", "vocabulary.ttl", diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java new file mode 100644 index 000000000..959707718 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java @@ -0,0 +1,22 @@ +package cz.cvut.kbss.termit.service.importer; + +import cz.cvut.kbss.termit.util.Constants; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.*; + +class ExcelImporterTest { + + @ParameterizedTest + @CsvSource({Constants.MediaType.EXCEL, "application/vnd.ms-excel"}) + void supportsMediaTypeReturnsTrueForSupportedExcelMediaType(String mediaType) { + assertTrue(ExcelImporter.supportsMediaType(mediaType)); + } + + @Test + void supportsMediaTypeReturnsFalseForUnsupportedMediaType() { + assertFalse(ExcelImporter.supportsMediaType("application/json")); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java new file mode 100644 index 000000000..50fef3951 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java @@ -0,0 +1,89 @@ +package cz.cvut.kbss.termit.service.importer; + +import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import cz.cvut.kbss.termit.util.Constants; +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; +import org.springframework.context.ApplicationContext; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VocabularyImportersTest { + + @Mock + private ExcelImporter excelImporter; + + @Mock + private SKOSImporter skosImporter; + + @Mock + private ApplicationContext appContext; + + @InjectMocks + private VocabularyImporters sut; + + private final Vocabulary importedVocabulary = Generator.generateVocabularyWithId(); + + @Test + void importVocabularyInvokesSkosImporterForRdfSkosInput() { + when(appContext.getBean(SKOSImporter.class)).thenReturn(skosImporter); + when(skosImporter.importVocabulary(any(), any())).thenReturn(importedVocabulary); + final VocabularyImporter.ImportConfiguration importConfig = new VocabularyImporter.ImportConfiguration(false, + Generator.generateUri(), + mock(Consumer.class)); + final VocabularyImporter.ImportInput importInput = new VocabularyImporter.ImportInput( + Constants.MediaType.TURTLE, new ByteArrayInputStream("data".getBytes( + StandardCharsets.UTF_8))); + final Vocabulary result = sut.importVocabulary(importConfig, importInput); + assertEquals(importedVocabulary, result); + verify(skosImporter).importVocabulary(importConfig, importInput); + } + + @Test + void importVocabularyInvokesExcelImporterForExcelInput() { + when(appContext.getBean(ExcelImporter.class)).thenReturn(excelImporter); + when(excelImporter.importVocabulary(any(), any())).thenReturn(importedVocabulary); + final VocabularyImporter.ImportConfiguration importConfig = new VocabularyImporter.ImportConfiguration(false, + Generator.generateUri(), + mock(Consumer.class)); + final VocabularyImporter.ImportInput importInput = new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + new ByteArrayInputStream( + "data".getBytes( + StandardCharsets.UTF_8))); + final Vocabulary result = sut.importVocabulary(importConfig, importInput); + assertEquals(importedVocabulary, result); + verify(excelImporter).importVocabulary(importConfig, importInput); + } + + @Test + void importVocabularyThrowsUnsupportedImportMediaTypeExceptionForUnsupportedMediaType() { + final VocabularyImporter.ImportConfiguration importConfig = new VocabularyImporter.ImportConfiguration(false, + Generator.generateUri(), + mock(Consumer.class)); + final VocabularyImporter.ImportInput importInput = new VocabularyImporter.ImportInput("text/csv", + new ByteArrayInputStream( + "data".getBytes( + StandardCharsets.UTF_8))); + + assertThrows(UnsupportedImportMediaTypeException.class, () -> sut.importVocabulary(importConfig, importInput)); + verify(skosImporter, never()).importVocabulary(any(), any()); + verify(excelImporter, never()).importVocabulary(any(), any()); + } +} From df2800d89c26ecc6c47e20e96721debc6b9ff655 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 25 Jun 2024 14:46:09 +0200 Subject: [PATCH 007/150] [kbss-cvut/termit-ui#449] Implement import of basic attributes from Excel. --- .../VocabularyDoesNotExistException.java | 10 ++ .../importing/VocabularyImportException.java | 5 + .../rest/handler/RestExceptionHandler.java | 2 +- .../service/importer/ExcelImporter.java | 34 ----- .../service/importer/VocabularyImporters.java | 1 + .../service/importer/excel/ExcelImporter.java | 80 ++++++++++ .../excel/LocalizedSheetImporter.java | 114 ++++++++++++++ .../cz/cvut/kbss/termit/util/Constants.java | 1 + src/main/resources/attributes/cs.properties | 9 ++ src/main/resources/attributes/en.properties | 11 ++ .../service/importer/ExcelImporterTest.java | 22 --- .../importer/VocabularyImportersTest.java | 1 + .../importer/excel/ExcelImporterTest.java | 143 ++++++++++++++++++ 13 files changed, 376 insertions(+), 57 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyDoesNotExistException.java delete mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java create mode 100644 src/main/resources/attributes/cs.properties create mode 100644 src/main/resources/attributes/en.properties delete mode 100644 src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java create mode 100644 src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java diff --git a/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyDoesNotExistException.java b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyDoesNotExistException.java new file mode 100644 index 000000000..947ef0784 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyDoesNotExistException.java @@ -0,0 +1,10 @@ +package cz.cvut.kbss.termit.exception.importing; + +/** + * Indicates that an existing vocabulary was expected for import but none was found. + */ +public class VocabularyDoesNotExistException extends VocabularyImportException { + public VocabularyDoesNotExistException(String message) { + super(message); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java index 68a81ce84..9a0dbca94 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java @@ -36,6 +36,11 @@ public VocabularyImportException(String message, String messageId) { this.messageId = messageId; } + public VocabularyImportException(String message, Throwable cause) { + super(message, cause); + this.messageId = null; + } + public String getMessageId() { return messageId; } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index b28baa022..2c53008ab 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -221,7 +221,7 @@ public ResponseEntity invalidLanguageConstantException(HttpServletReq } @ExceptionHandler - public ResponseEntity vocabularyImportException(HttpServletRequest request, + public ResponseEntity invalidTermStateException(HttpServletRequest request, InvalidTermStateException e) { logException(e, request); return new ResponseEntity<>( diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java deleted file mode 100644 index bc0d66da7..000000000 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java +++ /dev/null @@ -1,34 +0,0 @@ -package cz.cvut.kbss.termit.service.importer; - -import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.util.Constants; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; -import org.springframework.context.annotation.Scope; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; - -@Component -@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) -public class ExcelImporter implements VocabularyImporter { - - /** - * Media type for legacy .xls files. - */ - private static final String XLS_MEDIA_TYPE = "application/vnd.ms-excel"; - - @Override - public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) { - // TODO - return null; - } - - /** - * Checks whether this importer supports the specified media type. - * - * @param mediaType Media type to check - * @return {@code true} when media type is supported, {@code false} otherwise - */ - public static boolean supportsMediaType(@NonNull String mediaType) { - return Constants.MediaType.EXCEL.equals(mediaType) || XLS_MEDIA_TYPE.equals(mediaType); - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java index f10a4bc3a..d2782a9f1 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java @@ -3,6 +3,7 @@ import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import cz.cvut.kbss.termit.service.importer.excel.ExcelImporter; import org.springframework.context.ApplicationContext; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java new file mode 100644 index 000000000..97ebfc8f0 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -0,0 +1,80 @@ +package cz.cvut.kbss.termit.service.importer.excel; + +import cz.cvut.kbss.termit.exception.NotFoundException; +import cz.cvut.kbss.termit.exception.importing.VocabularyDoesNotExistException; +import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; +import cz.cvut.kbss.termit.model.Term; +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; +import cz.cvut.kbss.termit.service.repository.TermRepositoryService; +import cz.cvut.kbss.termit.util.Constants; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class ExcelImporter implements VocabularyImporter { + + /** + * Media type for legacy .xls files. + */ + private static final String XLS_MEDIA_TYPE = "application/vnd.ms-excel"; + + private final VocabularyDao vocabularyDao; + + private final TermRepositoryService termService; + + public ExcelImporter(VocabularyDao vocabularyDao, TermRepositoryService termService) { + this.vocabularyDao = vocabularyDao; + this.termService = termService; + } + + @Override + public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) { + Objects.requireNonNull(config); + Objects.requireNonNull(data); + if (config.vocabularyIri() == null || !vocabularyDao.exists(config.vocabularyIri())) { + throw new VocabularyDoesNotExistException("An existing vocabulary must be specified for Excel import."); + } + final Vocabulary targetVocabulary = vocabularyDao.find(config.vocabularyIri()).orElseThrow(() -> NotFoundException.create(Vocabulary.class, config.vocabularyIri())); + try { + for (InputStream input : data.data()) { + final Workbook workbook = new XSSFWorkbook(input); + assert workbook.getNumberOfSheets() > 0; + final Sheet sheet = workbook.getSheetAt(0); + // TODO Reuse terms between internationalized sheets + final List terms = new LocalizedSheetImporter().resolveTermsFromSheet(sheet); + // TODO Parents vs children + terms.forEach(t -> termService.addRootTermToVocabulary(t, targetVocabulary)); + } + } catch (IOException e) { + throw new VocabularyImportException("Unable to read input as Excel.", e); + } + return targetVocabulary; + } + + /** + * Checks whether this importer supports the specified media type. + * + * @param mediaType Media type to check + * @return {@code true} when media type is supported, {@code false} otherwise + */ + public static boolean supportsMediaType(@NonNull String mediaType) { + return Constants.MediaType.EXCEL.equals(mediaType) || XLS_MEDIA_TYPE.equals(mediaType); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java new file mode 100644 index 000000000..774645061 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -0,0 +1,114 @@ +package cz.cvut.kbss.termit.service.importer.excel; + +import com.neovisionaries.i18n.LanguageCode; +import cz.cvut.kbss.jopa.model.MultilingualString; +import cz.cvut.kbss.jopa.vocabulary.DC; +import cz.cvut.kbss.jopa.vocabulary.SKOS; +import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; +import cz.cvut.kbss.termit.model.Term; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +class LocalizedSheetImporter { + + private static final Logger LOG = LoggerFactory.getLogger(LocalizedSheetImporter.class); + + private Map attributeToColumn; + + List resolveTermsFromSheet(Sheet sheet) { + LOG.debug("Importing terms from sheet '{}'.", sheet.getSheetName()); + final LanguageCode lang = resolveLanguage(sheet); + final String langTag = lang.name(); + LOG.trace("Sheet '{}' mapped to language tage '{}'.", sheet.getSheetName(), langTag); + final Properties attributeMapping = new Properties(); + final Map labelToTerm = new LinkedHashMap<>(); + try { + attributeMapping.load( + getClass().getClassLoader().getResourceAsStream("attributes/" + langTag + ".properties")); + final Row attributes = sheet.getRow(0); + this.attributeToColumn = resolveAttributeColumns(attributes, attributeMapping); + } catch (IOException e) { + LOG.error("Unable to find attribute mapping for sheet {}. Skipping the sheet.", sheet.getSheetName(), e); + return Collections.emptyList(); + } + for (int i = 1; i < sheet.getLastRowNum(); i++) { + final Row termRow = sheet.getRow(i); + final Term term = new Term(); + final Optional label = getAttributeValue(termRow, SKOS.PREF_LABEL); + if (label.isEmpty()) { + LOG.trace("Reached empty label column cell at row {}. Finished processing sheet.", i); + break; + } + term.setLabel(MultilingualString.create(label.get(), langTag)); + getAttributeValue(termRow, SKOS.DEFINITION).ifPresent( + d -> term.setDefinition(MultilingualString.create(d, langTag))); + getAttributeValue(termRow, SKOS.SCOPE_NOTE).ifPresent( + sn -> term.setDescription(MultilingualString.create(sn, langTag))); + getAttributeValue(termRow, SKOS.ALT_LABEL).ifPresent(al -> term.setAltLabels( + splitIntoMultipleValues(al).stream().map(s -> MultilingualString.create(s, langTag)).collect( + Collectors.toSet()))); + getAttributeValue(termRow, SKOS.HIDDEN_LABEL).ifPresent(hl -> term.setHiddenLabels( + splitIntoMultipleValues(hl).stream().map(s -> MultilingualString.create(s, langTag)).collect( + Collectors.toSet()))); + getAttributeValue(termRow, SKOS.EXAMPLE).ifPresent(ex -> term.setExamples( + splitIntoMultipleValues(ex).stream().map(s -> MultilingualString.create(s, langTag)).collect( + Collectors.toSet()))); + getAttributeValue(termRow, DC.Terms.SOURCE).ifPresent(src -> term.setSources(splitIntoMultipleValues(src))); + getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); + getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( + nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); + labelToTerm.put(label.get(), term); + } + return new ArrayList<>(labelToTerm.values()); + } + + private static LanguageCode resolveLanguage(Sheet sheet) { + final List codes = LanguageCode.findByName(sheet.getSheetName()); + if (codes.isEmpty()) { + throw new VocabularyImportException("Unsupported sheet language " + sheet.getSheetName()); + } + return codes.get(0); + } + + private Map resolveAttributeColumns(Row attributes, Properties attributeMapping) { + final Map attributesToColumn = new HashMap<>(); + final Iterator it = attributes.cellIterator(); + while (it.hasNext()) { + final Cell cell = it.next(); + final String columnLabel = cell.getStringCellValue(); + for (Map.Entry e : attributeMapping.entrySet()) { + if (e.getValue().equals(columnLabel)) { + attributesToColumn.put(e.getKey().toString(), cell.getColumnIndex()); + break; + } + } + } + return attributesToColumn; + } + + private Optional getAttributeValue(Row row, String attributeIri) { + final String cellValue = row.getCell(attributeToColumn.get(attributeIri)).getStringCellValue(); + return cellValue.isBlank() ? Optional.empty() : Optional.of(cellValue.trim()); + } + + private Set splitIntoMultipleValues(String value) { + return Stream.of(value.split(",")).map(String::trim).collect(Collectors.toSet()); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index e493813bc..247a758ae 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -133,6 +133,7 @@ public class Constants { /** * Labels of columns representing exported term attributes in various supported languages. + * TODO Replace with constants loaded from attribute mapping properties files */ public static final Map> EXPORT_COLUMN_LABELS = Map.of( "cs", diff --git a/src/main/resources/attributes/cs.properties b/src/main/resources/attributes/cs.properties new file mode 100644 index 000000000..ed6b1dffb --- /dev/null +++ b/src/main/resources/attributes/cs.properties @@ -0,0 +1,9 @@ +http\://www.w3.org/2004/02/skos/core#prefLabel=Název +http\://www.w3.org/2004/02/skos/core#definition=Definice +http\://www.w3.org/2004/02/skos/core#scopeNote=Dopl?ující poznámka +http\://www.w3.org/2004/02/skos/core#altLabel=Synonyma +http\://www.w3.org/2004/02/skos/core#hiddenLabel=Vyhledávací texty +http\://www.w3.org/2004/02/skos/core#example=P?íklady +http\://www.w3.org/2004/02/skos/core#notation=Notace +http\://purl.org/dc/terms/source=Zdroj +http\://purl.org/dc/terms/references=Reference diff --git a/src/main/resources/attributes/en.properties b/src/main/resources/attributes/en.properties new file mode 100644 index 000000000..4205bca27 --- /dev/null +++ b/src/main/resources/attributes/en.properties @@ -0,0 +1,11 @@ +http\://www.w3.org/2004/02/skos/core#prefLabel=Label +http\://www.w3.org/2004/02/skos/core#definition=Definition +http\://www.w3.org/2004/02/skos/core#scopeNote=Scope note +http\://www.w3.org/2004/02/skos/core#altLabel=Synonyms +http\://www.w3.org/2004/02/skos/core#hiddenLabel=Search strings +http\://www.w3.org/2004/02/skos/core#example=Example +http\://www.w3.org/2004/02/skos/core#notation=Notation +http\://purl.org/dc/terms/source=Source +http\://purl.org/dc/terms/references=References + + diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java deleted file mode 100644 index 959707718..000000000 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package cz.cvut.kbss.termit.service.importer; - -import cz.cvut.kbss.termit.util.Constants; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -import static org.junit.jupiter.api.Assertions.*; - -class ExcelImporterTest { - - @ParameterizedTest - @CsvSource({Constants.MediaType.EXCEL, "application/vnd.ms-excel"}) - void supportsMediaTypeReturnsTrueForSupportedExcelMediaType(String mediaType) { - assertTrue(ExcelImporter.supportsMediaType(mediaType)); - } - - @Test - void supportsMediaTypeReturnsFalseForUnsupportedMediaType() { - assertFalse(ExcelImporter.supportsMediaType("application/json")); - } -} diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java index 50fef3951..acd1ceb92 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java @@ -4,6 +4,7 @@ import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import cz.cvut.kbss.termit.service.importer.excel.ExcelImporter; import cz.cvut.kbss.termit.util.Constants; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java new file mode 100644 index 000000000..954a23c2c --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -0,0 +1,143 @@ +package cz.cvut.kbss.termit.service.importer.excel; + +import cz.cvut.kbss.jopa.model.MultilingualString; +import cz.cvut.kbss.jopa.vocabulary.DC; +import cz.cvut.kbss.termit.environment.Environment; +import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.exception.importing.VocabularyDoesNotExistException; +import cz.cvut.kbss.termit.model.Term; +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; +import cz.cvut.kbss.termit.service.repository.TermRepositoryService; +import cz.cvut.kbss.termit.util.Constants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ExcelImporterTest { + + @Mock + private VocabularyDao vocabularyDao; + + @Mock + private TermRepositoryService termService; + + @Mock + private Consumer prePersist; + + @InjectMocks + private ExcelImporter sut; + + private Vocabulary vocabulary; + + @BeforeEach + void setUp() { + this.vocabulary = Generator.generateVocabularyWithId(); + } + + @ParameterizedTest + @CsvSource({Constants.MediaType.EXCEL, "application/vnd.ms-excel"}) + void supportsMediaTypeReturnsTrueForSupportedExcelMediaType(String mediaType) { + assertTrue(ExcelImporter.supportsMediaType(mediaType)); + } + + @Test + void supportsMediaTypeReturnsFalseForUnsupportedMediaType() { + assertFalse(ExcelImporter.supportsMediaType("application/json")); + } + + @Test + void importThrowsVocabularyDoesNotExistExceptionWhenNoVocabularyIdentifierIsProvided() { + assertThrows(VocabularyDoesNotExistException.class, + () -> sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, null, prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx")))); + } + + @Test + void importThrowsVocabularyDoesNotExistExceptionWhenVocabularyIsNotFound() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(false); + assertThrows(VocabularyDoesNotExistException.class, + () -> sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx")))); + } + + @Test + void importCreatesRootTermsWithBasicAttributesFromEnglishSheet() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + + final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(2, captor.getAllValues().size()); + final Optional building = captor.getAllValues().stream().filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + assertTrue(building.isPresent()); + assertEquals("Definition of term Building", building.get().getDefinition().get("en")); + assertEquals("Building scope note", building.get().getDescription().get("en")); + final Optional construction = captor.getAllValues().stream().filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + assertTrue(construction.isPresent()); + assertEquals("The process of building a building", construction.get().getDefinition().get("en")); + } + + @Test + void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + + final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-plural-atts-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(1, captor.getAllValues().size()); + final Term building = captor.getValue(); + assertEquals("Definition of term Building", building.getDefinition().get("en")); + assertEquals("Building scope note", building.getDescription().get("en")); + assertEquals(Set.of( + MultilingualString.create("Construction", "en"), + MultilingualString.create("Structure", "en"), + MultilingualString.create("House", "en") + ), building.getAltLabels()); + assertEquals(Set.of( + MultilingualString.create("bldng", "en"), + MultilingualString.create("strctr", "en"), + MultilingualString.create("haus", "en") + ), building.getHiddenLabels()); + assertEquals(Set.of( + MultilingualString.create("Dancing house", "en") + ), building.getExamples()); + assertEquals(Set.of("B"), building.getNotations()); + assertEquals(Set.of("a56"), building.getProperties().get(DC.Terms.REFERENCES)); + } +} From 839b85000955b665394ff16318bbf1a22ae56fe7 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 25 Jun 2024 16:13:37 +0200 Subject: [PATCH 008/150] [kbss-cvut/termit-ui#449] Support importing multilingual Excel without term references. --- .../service/importer/excel/ExcelImporter.java | 10 ++- .../excel/LocalizedSheetImporter.java | 87 +++++++++++++------ src/main/resources/attributes/cs.properties | 4 +- .../importer/excel/ExcelImporterTest.java | 54 ++++++++++++ 4 files changed, 125 insertions(+), 30 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index 97ebfc8f0..1ae7d2b4e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -53,12 +54,15 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) } final Vocabulary targetVocabulary = vocabularyDao.find(config.vocabularyIri()).orElseThrow(() -> NotFoundException.create(Vocabulary.class, config.vocabularyIri())); try { + List terms = Collections.emptyList(); for (InputStream input : data.data()) { final Workbook workbook = new XSSFWorkbook(input); assert workbook.getNumberOfSheets() > 0; - final Sheet sheet = workbook.getSheetAt(0); - // TODO Reuse terms between internationalized sheets - final List terms = new LocalizedSheetImporter().resolveTermsFromSheet(sheet); + for (int i = 0; i < workbook.getNumberOfSheets(); i++) { + final Sheet sheet = workbook.getSheetAt(i); + // TODO Reuse terms between internationalized sheets + terms = new LocalizedSheetImporter(terms).resolveTermsFromSheet(sheet); + } // TODO Parents vs children terms.forEach(t -> termService.addRootTermToVocabulary(t, targetVocabulary)); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 774645061..a433f4f5b 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -4,7 +4,6 @@ import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.jopa.vocabulary.SKOS; -import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; import cz.cvut.kbss.termit.model.Term; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; @@ -16,6 +15,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; @@ -23,6 +23,8 @@ import java.util.Optional; import java.util.Properties; import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -30,12 +32,22 @@ class LocalizedSheetImporter { private static final Logger LOG = LoggerFactory.getLogger(LocalizedSheetImporter.class); + private final List existingTerms; + private Map attributeToColumn; + private String langTag; + + LocalizedSheetImporter(List existingTerms) { + this.existingTerms = existingTerms; + } List resolveTermsFromSheet(Sheet sheet) { LOG.debug("Importing terms from sheet '{}'.", sheet.getSheetName()); - final LanguageCode lang = resolveLanguage(sheet); - final String langTag = lang.name(); + final Optional lang = resolveLanguage(sheet); + if (lang.isEmpty()) { + return existingTerms; + } + this.langTag = lang.get().name(); LOG.trace("Sheet '{}' mapped to language tage '{}'.", sheet.getSheetName(), langTag); final Properties attributeMapping = new Properties(); final Map labelToTerm = new LinkedHashMap<>(); @@ -50,41 +62,66 @@ List resolveTermsFromSheet(Sheet sheet) { } for (int i = 1; i < sheet.getLastRowNum(); i++) { final Row termRow = sheet.getRow(i); - final Term term = new Term(); + Term term = existingTerms.size() >= i ? existingTerms.get(i - 1) : new Term(); final Optional label = getAttributeValue(termRow, SKOS.PREF_LABEL); if (label.isEmpty()) { LOG.trace("Reached empty label column cell at row {}. Finished processing sheet.", i); break; } - term.setLabel(MultilingualString.create(label.get(), langTag)); - getAttributeValue(termRow, SKOS.DEFINITION).ifPresent( - d -> term.setDefinition(MultilingualString.create(d, langTag))); - getAttributeValue(termRow, SKOS.SCOPE_NOTE).ifPresent( - sn -> term.setDescription(MultilingualString.create(sn, langTag))); - getAttributeValue(termRow, SKOS.ALT_LABEL).ifPresent(al -> term.setAltLabels( - splitIntoMultipleValues(al).stream().map(s -> MultilingualString.create(s, langTag)).collect( - Collectors.toSet()))); - getAttributeValue(termRow, SKOS.HIDDEN_LABEL).ifPresent(hl -> term.setHiddenLabels( - splitIntoMultipleValues(hl).stream().map(s -> MultilingualString.create(s, langTag)).collect( - Collectors.toSet()))); - getAttributeValue(termRow, SKOS.EXAMPLE).ifPresent(ex -> term.setExamples( - splitIntoMultipleValues(ex).stream().map(s -> MultilingualString.create(s, langTag)).collect( - Collectors.toSet()))); - getAttributeValue(termRow, DC.Terms.SOURCE).ifPresent(src -> term.setSources(splitIntoMultipleValues(src))); - getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); - getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( - nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); + mapRowToTerm(term, label.get(), termRow); labelToTerm.put(label.get(), term); } return new ArrayList<>(labelToTerm.values()); } - private static LanguageCode resolveLanguage(Sheet sheet) { + private void mapRowToTerm(Term term, String label, Row termRow) { + initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label); + getAttributeValue(termRow, SKOS.DEFINITION).ifPresent( + d -> initSingularMultilingualString(term::getDefinition, term::setDefinition).set(langTag, d)); + getAttributeValue(termRow, SKOS.SCOPE_NOTE).ifPresent( + sn -> initSingularMultilingualString(term::getDescription, term::setDescription).set(langTag, sn)); + getAttributeValue(termRow, SKOS.ALT_LABEL).ifPresent(al -> populatePluralMultilingualString(term::getAltLabels, term::setAltLabels, splitIntoMultipleValues(al))); + getAttributeValue(termRow, SKOS.HIDDEN_LABEL).ifPresent(hl -> populatePluralMultilingualString(term::getHiddenLabels, term::setHiddenLabels, splitIntoMultipleValues(hl))); + getAttributeValue(termRow, SKOS.EXAMPLE).ifPresent(ex -> populatePluralMultilingualString(term::getExamples, term::setExamples, splitIntoMultipleValues(ex))); + getAttributeValue(termRow, DC.Terms.SOURCE).ifPresent(src -> term.setSources(splitIntoMultipleValues(src))); + getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); + getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( + nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); + } + + private MultilingualString initSingularMultilingualString(Supplier getter, + Consumer setter) { + if (getter.get() == null) { + setter.accept(new MultilingualString()); + } + return getter.get(); + } + + private void populatePluralMultilingualString( + Supplier> getter, Consumer> setter, Set values) { + Set attValue = getter.get(); + if (attValue == null) { + setter.accept(new HashSet<>()); + attValue = getter.get(); + } + for (String s : values) { + final Optional mls = attValue.stream().filter(m -> !m.contains(langTag)).findFirst(); + if (mls.isPresent()) { + mls.get().set(langTag, s); + } else { + final MultilingualString newMls = MultilingualString.create(s, langTag); + attValue.add(newMls); + } + } + } + + private static Optional resolveLanguage(Sheet sheet) { final List codes = LanguageCode.findByName(sheet.getSheetName()); if (codes.isEmpty()) { - throw new VocabularyImportException("Unsupported sheet language " + sheet.getSheetName()); + LOG.debug("No matching language found for sheet '{}'. Skipping it.", sheet.getSheetName()); + return Optional.empty(); } - return codes.get(0); + return Optional.of(codes.get(0)); } private Map resolveAttributeColumns(Row attributes, Properties attributeMapping) { diff --git a/src/main/resources/attributes/cs.properties b/src/main/resources/attributes/cs.properties index ed6b1dffb..1172baa56 100644 --- a/src/main/resources/attributes/cs.properties +++ b/src/main/resources/attributes/cs.properties @@ -1,9 +1,9 @@ http\://www.w3.org/2004/02/skos/core#prefLabel=Název http\://www.w3.org/2004/02/skos/core#definition=Definice -http\://www.w3.org/2004/02/skos/core#scopeNote=Dopl?ující poznámka +http\://www.w3.org/2004/02/skos/core#scopeNote=Dopl\u0148ující poznámka http\://www.w3.org/2004/02/skos/core#altLabel=Synonyma http\://www.w3.org/2004/02/skos/core#hiddenLabel=Vyhledávací texty -http\://www.w3.org/2004/02/skos/core#example=P?íklady +http\://www.w3.org/2004/02/skos/core#example=P\u0159íklady http\://www.w3.org/2004/02/skos/core#notation=Notace http\://purl.org/dc/terms/source=Zdroj http\://purl.org/dc/terms/references=Reference diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 954a23c2c..03909a118 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -140,4 +140,58 @@ void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { assertEquals(Set.of("B"), building.getNotations()); assertEquals(Set.of("a56"), building.getProperties().get(DC.Terms.REFERENCES)); } + + @Test + void importCreatesRootTermsWithBasicAttributesFromMultipleTranslationSheets() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + + final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en-cs.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(2, captor.getAllValues().size()); + final Optional building = captor.getAllValues().stream().filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + assertTrue(building.isPresent()); + assertEquals("Budova", building.get().getLabel().get("cs")); + assertEquals("Definition of term Building", building.get().getDefinition().get("en")); + assertEquals("Definice pojmu budova", building.get().getDefinition().get("cs")); + assertEquals("Building scope note", building.get().getDescription().get("en")); + assertEquals("Doplňující poznámka pojmu budova", building.get().getDescription().get("cs")); + final Optional construction = captor.getAllValues().stream().filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + assertTrue(construction.isPresent()); + assertEquals("Stavba", construction.get().getLabel().get("cs")); + assertEquals("The process of building a building", construction.get().getDefinition().get("en")); + assertEquals("Proces výstavby budovy", construction.get().getDefinition().get("cs")); + } + + @Test + void importCreatesRootTermsWithPluralBasicAttributesFromMultipleTranslationSheets() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + + final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-plural-atts-en-cs.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(1, captor.getAllValues().size()); + final Term building = captor.getValue(); + assertEquals("Budova", building.getLabel().get("cs")); + assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("Structure"))); + assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("House"))); + assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("dům"))); + assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("stavba"))); + assertTrue(building.getHiddenLabels().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("bldng"))); + assertTrue(building.getHiddenLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("barák"))); + assertTrue(building.getExamples().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("Dancing house"))); + assertTrue(building.getExamples().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("TanÄící dům"))); + assertEquals(Set.of("B"), building.getNotations()); + assertEquals(Set.of("a56"), building.getProperties().get(DC.Terms.REFERENCES)); + } } From 3ae9137fcc5d579907de45873beefc7f2a8820ec Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 25 Jun 2024 16:24:55 +0200 Subject: [PATCH 009/150] [kbss-cvut/termit-ui#449] Fix properties file encoding issues. --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index b3e482c40..6b524a424 100644 --- a/pom.xml +++ b/pom.xml @@ -566,6 +566,7 @@ copy-resources + ISO-8859-1 xlsx From 84c8d0acf00b1cb395f423c78bfab73f9e9f849f Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 10 Jun 2024 16:00:30 +0200 Subject: [PATCH 010/150] [kbss-cvut/termit-ui#449] Add Excel template file for importing vocabularies. --- src/main/resources/template/termit-import.xlsx | Bin 0 -> 67470 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/main/resources/template/termit-import.xlsx diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..94c7b2ae750946fd211c7bdf78c795896b74dd32 GIT binary patch literal 67470 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 literal 0 HcmV?d00001 From 30e68dbda4642fbcb829e8aac113682ac30a9ba7 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 18 Jun 2024 13:28:55 +0200 Subject: [PATCH 011/150] [kbss-cvut/termit-ui#449] Allow downloading Excel import template file. --- .../termit/rest/VocabularyController.java | 16 +++++++++ .../service/business/VocabularyService.java | 34 +++++++++++++++++-- .../util/TypeAwareFileSystemResource.java | 7 ++++ .../kbss/termit/util/TypeAwareResource.java | 6 ++-- .../termit/rest/VocabularyControllerTest.java | 14 ++++++++ .../repository/VocabularyServiceTest.java | 33 ++++++++++++++++-- 6 files changed, 103 insertions(+), 7 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 31590c906..919de17f6 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -34,6 +34,7 @@ import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants.QueryParams; +import cz.cvut.kbss.termit.util.TypeAwareResource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -43,6 +44,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -192,6 +194,20 @@ public ResponseEntity createVocabulary( return ResponseEntity.created(locationWithout(generateLocation(vocabulary.getUri()), "/import")).build(); } + @Operation(description = "Gets a template Excel file that can be used to import terms into TermIt") + @ApiResponse(responseCode = "200", description = "Template Excel file is returned as attachment") + @GetMapping("/import/template") + @PreAuthorize("permitAll()") + public ResponseEntity getExcelTemplateFile() { + final TypeAwareResource template = vocabularyService.getExcelTemplateFile(); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType( + template.getMediaType().orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE))) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + template.getFilename() + "\"") + .body(template); + } + URI locationWithout(URI location, String toRemove) { return URI.create(location.toString().replace(toRemove, "")); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index e0374c4f8..b13dcd115 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -25,6 +25,7 @@ import cz.cvut.kbss.termit.dto.listing.VocabularyDto; import cz.cvut.kbss.termit.event.VocabularyCreatedEvent; import cz.cvut.kbss.termit.exception.NotFoundException; +import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.acl.AccessControlList; import cz.cvut.kbss.termit.model.acl.AccessControlRecord; @@ -35,10 +36,14 @@ import cz.cvut.kbss.termit.persistence.snapshot.SnapshotCreator; import cz.cvut.kbss.termit.service.business.async.AsyncTermService; import cz.cvut.kbss.termit.service.changetracking.ChangeRecordProvider; +import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; +import cz.cvut.kbss.termit.service.export.ExportFormat; +import cz.cvut.kbss.termit.service.export.ExportType; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; +import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -53,9 +58,18 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.io.File; import java.net.URI; +import java.net.URISyntaxException; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; /** * Business logic concerning vocabularies. @@ -130,7 +144,8 @@ public Optional find(URI id) { @PostAuthorize("@vocabularyAuthorizationService.canRead(returnObject)") public Vocabulary findRequired(URI id) { // Enhance vocabulary data with info on current user's access level - final cz.cvut.kbss.termit.dto.VocabularyDto dto = new cz.cvut.kbss.termit.dto.VocabularyDto(repositoryService.findRequired(id)); + final cz.cvut.kbss.termit.dto.VocabularyDto dto = new cz.cvut.kbss.termit.dto.VocabularyDto( + repositoryService.findRequired(id)); dto.setAccessLevel(getAccessLevel(dto)); return dto; } @@ -221,6 +236,21 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { return repositoryService.importVocabulary(vocabularyIri, file); } + /** + * Gets an Excel template file that can be used to import terms into TermIt. + * + * @return Template file as a resource + */ + public TypeAwareResource getExcelTemplateFile() { + try { + assert getClass().getClassLoader().getResource("template/termit-import.xlsx") != null; + final File template = new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); + return new TypeAwareFileSystemResource(template, ExportFormat.EXCEL.getMediaType()); + } catch (URISyntaxException e) { + throw new TermItException("Fatal error, unable to load Excel template file.", e); + } + } + @Override public List getChanges(Vocabulary asset) { return changeRecordService.getChanges(asset); diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java b/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java index 01913f836..fa6898044 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java @@ -41,6 +41,13 @@ public Optional getMediaType() { return Optional.ofNullable(mediaType); } + @Override + public Optional getFileExtension() { + assert getFilename() != null; + return getFilename().contains(".") ? Optional.of(getFilename().substring(getFilename().lastIndexOf("."))) : + Optional.empty(); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java index d52ea7be0..211b199e1 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java +++ b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java @@ -24,8 +24,8 @@ /** * An IO resource aware of its media type. *

- * Allows to get MIME type of the resource and the associated file extension. However, both methods return {@link Optional} - * to accommodate resources which may not support this feature. + * Allows to get MIME type of the resource and the associated file extension. However, both methods return + * {@link Optional} to accommodate resources which may not support this feature. */ public interface TypeAwareResource extends Resource { @@ -40,6 +40,8 @@ default Optional getMediaType() { /** * Gets file extension of this resource (if supported). + *

+ * The file extension is including the dot, so, for example, {@literal .csv}. * * @return File extension associated with this type of resource wrapped in {@code Optional} */ diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index 962756539..10300a6a5 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -38,6 +38,7 @@ import cz.cvut.kbss.termit.rest.handler.ErrorInfo; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.VocabularyService; +import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; @@ -56,6 +57,7 @@ import org.springframework.test.web.servlet.MvcResult; import org.topbraid.shacl.vocabulary.SH; +import java.io.File; import java.math.BigInteger; import java.net.URI; import java.time.Instant; @@ -635,4 +637,16 @@ void getAccessLevelRetrievesAccessLevelToSpecifiedVocabulary() throws Exception assertEquals(AccessLevel.SECURITY, result); verify(serviceMock).getAccessLevel(vocabulary); } + + @Test + void getExcelTemplateFileReturnsExcelTemplateFileRetrievedFromServiceAsAttachment() throws Exception { + when(serviceMock.getExcelTemplateFile()).thenReturn(new TypeAwareFileSystemResource( + new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()), + Constants.MediaType.EXCEL)); + + final MvcResult mvcResult = mockMvc.perform(get(PATH + "/import/template")).andReturn(); + assertThat(mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION), containsString("attachment")); + assertThat(mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION), + containsString("filename=\"termit-import.xlsx\"")); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java index e9f76513e..caa02710d 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java @@ -37,7 +37,9 @@ import cz.cvut.kbss.termit.service.business.AccessControlListService; import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.service.business.async.AsyncTermService; +import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; +import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -53,12 +55,26 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; +import java.io.File; import java.nio.charset.StandardCharsets; -import java.util.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; import static cz.cvut.kbss.termit.environment.Environment.termsToDtos; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +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.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class VocabularyServiceTest { @@ -356,4 +372,15 @@ void importNewVocabularyPublishesVocabularyCreatedEvent() { assertInstanceOf(VocabularyCreatedEvent.class, captor.getValue()); assertEquals(persisted, captor.getValue().getSource()); } + + @Test + void getExcelTemplateFileReturnsResourceRepresentingExcelTemplateFile() throws Exception { + final TypeAwareResource result = sut.getExcelTemplateFile(); + assertTrue(result.getFileExtension().isPresent()); + assertEquals(ExportFormat.EXCEL.getFileExtension(), result.getFileExtension().get()); + assertTrue(result.getMediaType().isPresent()); + assertEquals(ExportFormat.EXCEL.getMediaType(), result.getMediaType().get()); + final File expectedFile = new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); + assertEquals(expectedFile, result.getFile()); + } } From e14111174f7a08d0028a71f156dfe5eb143340a5 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 18 Jun 2024 14:32:46 +0200 Subject: [PATCH 012/150] [kbss-cvut/termit-ui#449] Add updated Excel template with value lists for term types and states. --- .../resources/template/termit-import.xlsx | Bin 67470 -> 66960 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx index 94c7b2ae750946fd211c7bdf78c795896b74dd32..fb1fab8261127be534fc916b5fcd71ae07f0c52f 100644 GIT binary patch literal 66960 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 From 789ae83c504e1767e7f85092d2a40255e9ca8b44 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 18 Jun 2024 14:51:45 +0200 Subject: [PATCH 013/150] [kbss-cvut/termit-ui#449] Make Excel import template file configurable. Mainly to allow aligning value lists in the Excel with value taxonomy languages (term types, states) used by TermIt instance. --- .../service/business/VocabularyService.java | 19 +++--- .../cvut/kbss/termit/util/Configuration.java | 60 +++++++++++++++---- .../repository/VocabularyServiceTest.java | 2 + 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index b13dcd115..48d48bc59 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -38,11 +38,11 @@ import cz.cvut.kbss.termit.service.changetracking.ChangeRecordProvider; import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.service.export.ExportFormat; -import cz.cvut.kbss.termit.service.export.ExportType; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; +import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -242,13 +242,16 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { * @return Template file as a resource */ public TypeAwareResource getExcelTemplateFile() { - try { - assert getClass().getClassLoader().getResource("template/termit-import.xlsx") != null; - final File template = new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); - return new TypeAwareFileSystemResource(template, ExportFormat.EXCEL.getMediaType()); - } catch (URISyntaxException e) { - throw new TermItException("Fatal error, unable to load Excel template file.", e); - } + final Configuration config = context.getBean(Configuration.class); + final File templateFile = config.getTemplate().getExcelImport().map(File::new).orElseGet(() -> { + try { + assert getClass().getClassLoader().getResource("template/termit-import.xlsx") != null; + return new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); + } catch (URISyntaxException e) { + throw new TermItException("Fatal error, unable to load Excel template file.", e); + } + }); + return new TypeAwareFileSystemResource(templateFile, ExportFormat.EXCEL.getMediaType()); } @Override diff --git a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java index 6b2d1eebe..5f43aa236 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java @@ -35,7 +35,8 @@ * The runtime configuration consists of predefined default values and configuration loaded from config files on * classpath. Values from config files supersede the default values. *

- * The configuration can be also set via OS + * The configuration can be also set via OS * environment variables. These override any statically configured values. */ @ConfigurationProperties("termit") @@ -91,6 +92,8 @@ public class Configuration { private Security security = new Security(); @Valid private Language language = new Language(); + @Valid + private Template template = new Template(); public String getUrl() { return url; @@ -252,6 +255,14 @@ public void setLanguage(Language language) { this.language = language; } + public Template getTemplate() { + return template; + } + + public void setTemplate(Template template) { + this.template = template; + } + @Validated public static class Persistence { /** @@ -409,10 +420,11 @@ public static class Namespace { * Since Term identifier is given by the identifier of the Vocabulary it belongs to and its own normalized * label, this separator is used to (optionally) configure the Term identifier namespace. *

- * For example, if we have a Vocabulary with IRI {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan} - * and a Term with normalized label {@code inhabited-area}, the resulting IRI will be {@code - * http://www.example.org/ontologies/vocabularies/metropolitan-plan/SEPARATOR/inhabited-area}, where 'SEPARATOR' - * is the value of this configuration parameter. + * For example, if we have a Vocabulary with IRI + * {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan} and a Term with normalized label + * {@code inhabited-area}, the resulting IRI will be + * {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan/SEPARATOR/inhabited-area}, where + * 'SEPARATOR' is the value of this configuration parameter. */ @Valid private NamespaceDetail term = new NamespaceDetail(); @@ -422,9 +434,10 @@ public static class Namespace { * Since File identifier is given by the identifier of the Document it belongs to and its own normalized label, * this separator is used to (optionally) configure the File identifier namespace. *

- * For example, if we have a Document with IRI {@code http://www.example.org/ontologies/resources/metropolitan-plan/document} - * and a File with normalized label {@code main-file}, the resulting IRI will be {@code - * http://www.example.org/ontologies/resources/metropolitan-plan/document/SEPARATOR/main-file}, where + * For example, if we have a Document with IRI + * {@code http://www.example.org/ontologies/resources/metropolitan-plan/document} and a File with normalized + * label {@code main-file}, the resulting IRI will be + * {@code http://www.example.org/ontologies/resources/metropolitan-plan/document/SEPARATOR/main-file}, where * 'SEPARATOR' is the value of this configuration parameter. */ @Valid @@ -433,8 +446,9 @@ public static class Namespace { /** * Separator of snapshot timestamp and original asset identifier. *

- * For example, if we have a Vocabulary with IRI {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan} - * and the snapshot separator is configured to {@code version}, a snapshot IRI will look something like + * For example, if we have a Vocabulary with IRI + * {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan} and the snapshot separator is + * configured to {@code version}, a snapshot IRI will look something like * {@code http://www.example.org/ontologies/vocabularies/metropolitan-plan/version/20220530T202317Z}. */ @Valid @@ -867,8 +881,8 @@ public static class Language { private LanguageSource types = new LanguageSource(); /** - * Path to a file containing definition of the language of states terms can be in. The file must be in - * Turtle format. The term definitions must use SKOS terminology for attributes (prefLabel, scopeNote and + * Path to a file containing definition of the language of states terms can be in. The file must be in Turtle + * format. The term definitions must use SKOS terminology for attributes (prefLabel, scopeNote and * broader/narrower). */ @Valid @@ -903,4 +917,26 @@ public void setSource(String source) { } } } + + @Validated + public static class Template { + + /** + * Template file for Excel import. + *

+ * The purpose of configuring this file is mainly to have the value lists for term types and states in the + * template aligned with the corresponding languages used by TermIt. + *

+ * Empty value means the built-in template file should be used. + */ + private Optional excelImport = Optional.empty(); + + public Optional getExcelImport() { + return excelImport; + } + + public void setExcelImport(Optional excelImport) { + this.excelImport = excelImport; + } + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java index caa02710d..a66829ab1 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java @@ -39,6 +39,7 @@ import cz.cvut.kbss.termit.service.business.async.AsyncTermService; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; +import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; @@ -375,6 +376,7 @@ void importNewVocabularyPublishesVocabularyCreatedEvent() { @Test void getExcelTemplateFileReturnsResourceRepresentingExcelTemplateFile() throws Exception { + when(appContext.getBean(Configuration.class)).thenReturn(new Configuration()); final TypeAwareResource result = sut.getExcelTemplateFile(); assertTrue(result.getFileExtension().isPresent()); assertEquals(ExportFormat.EXCEL.getFileExtension(), result.getFileExtension().get()); From dc3bace113e3f600cdb1f676f5ea4a50ebc0138a Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Wed, 19 Jun 2024 08:02:40 +0200 Subject: [PATCH 014/150] [kbss-cvut/termit-ui#449] Refactor SKOSImporter - provide a common VocabularyImporter interface. --- .../persistence/dao/skos/SKOSImporter.java | 50 ++---- .../service/importer/VocabularyImporter.java | 51 ++++++ .../VocabularyRepositoryService.java | 9 +- .../dao/skos/SKOSImporterTest.java | 167 ++++++++++-------- ...VocabularyRepositoryServiceImportTest.java | 14 +- 5 files changed, 173 insertions(+), 118 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporter.java diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java index 663822ee7..514f99e05 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java @@ -25,6 +25,7 @@ import cz.cvut.kbss.termit.model.Glossary; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; import org.eclipse.rdf4j.model.IRI; @@ -71,7 +72,7 @@ */ @Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) -public class SKOSImporter { +public class SKOSImporter implements VocabularyImporter { private static final Logger LOG = LoggerFactory.getLogger(SKOSImporter.class); @@ -102,49 +103,17 @@ public SKOSImporter(Configuration config, VocabularyDao vocabularyDao, EntityMan this.em = em; } - /** - * Imports a new vocabulary from the specified streams representing the vocabulary in SKOS format. - * - * @param rename Whether to change vocabulary, glossary and term IRIs in case of a conflict with existing - * data - * @param mediaType Input data media type - * @param persist Consumer of the imported vocabulary, used to save the imported data - * @param inputStreams Streams containing the imported SKOS data - * @return The imported vocabulary - * @throws VocabularyExistsException If a vocabulary/glossary with the same identifier already exists and - * {@code rename} is set to {@code false} - * @throws IllegalArgumentException Indicates invalid input data, e.g., no input streams, missing language tags - * etc. - */ - public Vocabulary importVocabulary(boolean rename, String mediaType, final Consumer persist, - final InputStream... inputStreams) { - return importVocabulary(rename, null, mediaType, persist, inputStreams); - } - - /** - * Imports a SKOS vocabulary from the specified streams, possibly replacing an existing one. - *

- * If the specified {@code vocabularyIri} identifies an existing vocabulary, its content is replaced with the - * imported data. - * - * @param vocabularyIri Target vocabulary identifier - * @param mediaType Input data media type - * @param persist Consumer of the imported vocabulary, used to save the imported data - * @param inputStreams Streams containing the imported SKOS data - * @return The imported vocabulary - * @throws IllegalArgumentException Indicates invalid input data, e.g., no input streams, missing language tags - * etc. - */ - public Vocabulary importVocabulary(URI vocabularyIri, String mediaType, final Consumer persist, - final InputStream... inputStreams) { - Objects.requireNonNull(vocabularyIri); - return importVocabulary(false, vocabularyIri, mediaType, persist, inputStreams); + @Override + public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) { + Objects.requireNonNull(config); + Objects.requireNonNull(data); + return importVocabulary(config.allowReIdentify(), config.vocabularyIri(), data.mediaType(), config.prePersist(), data.data()); } private Vocabulary importVocabulary(final boolean rename, final URI vocabularyIri, final String mediaType, - final Consumer persist, + final Consumer prePersist, final InputStream... inputStreams) { if (inputStreams.length == 0) { throw new IllegalArgumentException("No input provided for importing vocabulary."); @@ -184,7 +153,8 @@ private Vocabulary importVocabulary(final boolean rename, em.flush(); em.clear(); - persist.accept(vocabulary); + prePersist.accept(vocabulary); + vocabularyDao.persist(vocabulary); addDataIntoRepository(vocabulary.getUri()); LOG.debug("Vocabulary import successfully finished."); return vocabulary; diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporter.java new file mode 100644 index 000000000..81b2ab820 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporter.java @@ -0,0 +1,51 @@ +package cz.cvut.kbss.termit.service.importer; + +import cz.cvut.kbss.termit.exception.importing.VocabularyExistsException; +import cz.cvut.kbss.termit.model.Vocabulary; + +import java.io.InputStream; +import java.net.URI; +import java.util.function.Consumer; + +/** + * Supports importing vocabularies. + */ +public interface VocabularyImporter { + + /** + * Imports vocabulary from the specified data. + *

+ * Import configuration allows to specify handling of existing vocabulary data or target vocabulary identifier. + * + * @param config Import configuration + * @param data Data to import + * @return Imported vocabulary + * @throws VocabularyExistsException If a vocabulary/glossary with the same identifier already exists and + * {@code config} does not allow renaming or overwriting + * @throws IllegalArgumentException Indicates invalid input data, e.g., no input streams, missing language tags + * etc. + */ + Vocabulary importVocabulary(ImportConfiguration config, ImportInput data); + + /** + * Vocabulary import configuration. + * + * @param allowReIdentify Whether to allow modifying identifiers when repository already contains data with matching + * identifiers + * @param vocabularyIri Identifier of the target vocabulary, optional. If specified, any pre-existing data are + * overwritten + * @param prePersist Procedure to call before persisting the resulting vocabulary + */ + record ImportConfiguration(boolean allowReIdentify, URI vocabularyIri, + Consumer prePersist) { + } + + /** + * Data to import. + * + * @param mediaType Media type of the imported data + * @param data Streams containing the data + */ + record ImportInput(String mediaType, InputStream... data) { + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index 1c23c5b4e..6f79993ee 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -35,6 +35,7 @@ import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.MessageFormatter; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; @@ -230,7 +231,9 @@ public Vocabulary importVocabulary(boolean rename, MultipartFile file) { Objects.requireNonNull(file); try { String contentType = resolveContentType(file); - return getSKOSImporter().importVocabulary(rename, contentType, this::persist, file.getInputStream()); + return getSKOSImporter().importVocabulary( + new VocabularyImporter.ImportConfiguration(rename, null, this::initDocument), + new VocabularyImporter.ImportInput(contentType, file.getInputStream())); } catch (VocabularyImportException e) { throw e; } catch (Exception e) { @@ -251,7 +254,9 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { Objects.requireNonNull(file); try { String contentType = resolveContentType(file); - return getSKOSImporter().importVocabulary(vocabularyIri, contentType, this::persist, file.getInputStream()); + return getSKOSImporter().importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabularyIri, this::initDocument), + new VocabularyImporter.ImportInput(contentType, file.getInputStream())); } catch (VocabularyImportException e) { throw e; } catch (Exception e) { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java index 85acf422c..88c9e20be 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java @@ -30,6 +30,7 @@ import cz.cvut.kbss.termit.persistence.context.DescriptorFactory; import cz.cvut.kbss.termit.persistence.dao.BaseDaoTestRunner; import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Vocabulary; import org.eclipse.rdf4j.model.Resource; @@ -48,6 +49,7 @@ import org.springframework.test.annotation.DirtiesContext; import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -110,8 +112,9 @@ void setUp() { void importVocabularyImportsGlossaryFromSpecifiedStream() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final Repository repo = em.unwrap(Repository.class); @@ -131,13 +134,15 @@ void importVocabularyImportsGlossaryFromSpecifiedStream() { void importVocabularyRenamesVocabularyIriWhenAlreadyPresent() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final Repository repo = em.unwrap(Repository.class); @@ -153,13 +158,15 @@ void importVocabularyRenamesVocabularyIriWhenAlreadyPresent() { void importVocabularyRenamesTermIriUponRenamingVocabularyIri() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final List vocabularies = vocabularyDao.findAll(); @@ -180,7 +187,10 @@ void importThrowsIllegalArgumentExceptionWhenNoStreamIsProvided() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); assertThrows(IllegalArgumentException.class, - () -> sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister)); + () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + (InputStream) null))); }); } @@ -189,9 +199,11 @@ void importThrowsIllegalArgumentExceptionWhenVocabularyIriIsGivenButDoesNotMatch transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); assertThrows(IllegalArgumentException.class, () -> - sut.importVocabulary(URI.create(VOCABULARY_IRI_S + "-1"), Constants.MediaType.TURTLE, - persister, Environment.loadFile("data/test-glossary.ttl")) - ); + sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, URI.create(VOCABULARY_IRI_S + "-1"), + persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl")))); }); } @@ -207,8 +219,9 @@ void importInsertsImportedDataIntoContextBasedOnOntologyIdentifier() { }); transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); transactional(() -> { final Repository repo = em.unwrap(Repository.class); @@ -230,9 +243,10 @@ void importInsertsImportedDataIntoContextBasedOnOntologyIdentifier() { void importResolvesVocabularyIriForContextWhenMultipleStreamsWithGlossaryAndVocabularyAreProvided() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { final Repository repo = em.unwrap(Repository.class); @@ -264,13 +278,15 @@ void importThrowsIllegalArgumentExceptionWhenTargetContextCannotBeDeterminedFrom transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); final VocabularyImportException ex = assertThrows(VocabularyImportException.class, - () -> sut - .importVocabulary(VOCABULARY_IRI, - Constants.MediaType.TURTLE, - persister, - new ByteArrayInputStream( - input.getBytes( - StandardCharsets.UTF_8)))); + () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, + VOCABULARY_IRI, + persister), + new VocabularyImporter.ImportInput( + Constants.MediaType.TURTLE, + new ByteArrayInputStream( + input.getBytes( + StandardCharsets.UTF_8))))); assertThat(ex.getMessage(), containsString("No unique skos:ConceptScheme found in the provided data.")); }); } @@ -279,9 +295,10 @@ void importThrowsIllegalArgumentExceptionWhenTargetContextCannotBeDeterminedFrom void importThrowsUnsupportedImportMediaTypeExceptionForUnsupportedDataType() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - assertThrows(UnsupportedImportMediaTypeException.class, () -> sut - .importVocabulary(VOCABULARY_IRI, Constants.MediaType.EXCEL, persister, - Environment.loadFile("data/test-glossary.ttl"))); + assertThrows(UnsupportedImportMediaTypeException.class, () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile("data/test-glossary.ttl")))); }); } @@ -289,9 +306,10 @@ void importThrowsUnsupportedImportMediaTypeExceptionForUnsupportedDataType() { void importReturnsVocabularyInstanceConstructedFromImportedData() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - final cz.cvut.kbss.termit.model.Vocabulary result = sut - .importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + final cz.cvut.kbss.termit.model.Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); assertNotNull(result); assertEquals(VOCABULARY_IRI, result.getUri()); assertEquals("Vocabulary of system TermIt - glossary", result.getPrimaryLabel()); @@ -302,14 +320,15 @@ void importReturnsVocabularyInstanceConstructedFromImportedData() { void importGeneratesRelationshipsBetweenTermsAndGlossary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { try (final RepositoryConnection conn = em.unwrap(Repository.class).getConnection()) { final List terms = conn.getStatements(null, RDF.TYPE, SKOS.CONCEPT).stream() - .map(Statement::getSubject).toList(); + .map(Statement::getSubject).toList(); assertFalse(terms.isEmpty()); terms.forEach(t -> assertTrue(conn.getStatements(t, SKOS.IN_SCHEME, vf.createIRI(GLOSSARY_IRI)).hasNext())); @@ -321,14 +340,15 @@ void importGeneratesRelationshipsBetweenTermsAndGlossary() { void importGeneratesTopConceptAssertions() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { try (final RepositoryConnection conn = em.unwrap(Repository.class).getConnection()) { final List terms = conn.getStatements(null, SKOS.HAS_TOP_CONCEPT, null).stream() - .map(Statement::getObject).collect(Collectors.toList()); + .map(Statement::getObject).collect(Collectors.toList()); assertEquals(1, terms.size()); assertThat(terms, hasItem(vf.createIRI(Vocabulary.s_c_uzivatel_termitu))); } @@ -339,14 +359,15 @@ void importGeneratesTopConceptAssertions() { void importGeneratesTopConceptAssertionsForGlossaryUsingNarrowerProperty() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary-narrower.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, Environment.loadFile( + "data/test-glossary-narrower.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { try (final RepositoryConnection conn = em.unwrap(Repository.class).getConnection()) { final List terms = conn.getStatements(null, SKOS.HAS_TOP_CONCEPT, null).stream() - .map(Statement::getObject).collect(Collectors.toList()); + .map(Statement::getObject).collect(Collectors.toList()); assertEquals(1, terms.size()); assertThat(terms, hasItem(vf.createIRI(Vocabulary.s_c_uzivatel_termitu))); } @@ -358,10 +379,12 @@ void importFailsIfAnEmptyLanguageTagIsProvidedForMultilingualProperties() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); assertThrows(IllegalArgumentException.class, () -> - sut.importVocabulary(true, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary-narrower.ttl"), - Environment.loadFile( - "data/test-glossary-with-definition-with-empty-language-tag.ttl"))); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(true, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile( + "data/test-glossary.ttl"), + Environment.loadFile( + "data/test-glossary-with-definition-with-empty-language-tag.ttl")))); }); } @@ -369,11 +392,11 @@ void importFailsIfAnEmptyLanguageTagIsProvidedForMultilingualProperties() { void importThrowsVocabularyExistsExceptionWhenGlossaryExistsForNewVocabulary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - assertThrows(VocabularyExistsException.class, () -> sut.importVocabulary(false, - Constants.MediaType.TURTLE, - persister, - Environment.loadFile( - "data/test-glossary.ttl"))); + assertThrows(VocabularyExistsException.class, + () -> sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, null, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile( + "data/test-glossary.ttl")))); }); } @@ -381,8 +404,9 @@ void importThrowsVocabularyExistsExceptionWhenGlossaryExistsForNewVocabulary() { void importOverridesExistingGlossaryWhenImportingExistingVocabulary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); final Glossary result = em.find(Glossary.class, URI.create(GLOSSARY_IRI)); @@ -401,8 +425,9 @@ void importConnectsExistingDocumentToReimportedVocabulary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); final cz.cvut.kbss.termit.model.Vocabulary result = findVocabulary(); assertNotNull(result); @@ -421,14 +446,15 @@ private cz.cvut.kbss.termit.model.Vocabulary findVocabulary() { void importSkipsAssertedTopConceptOfStatements() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary-with-topconceptof.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, Environment.loadFile( + "data/test-glossary-with-topconceptof.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); transactional(() -> { try (final RepositoryConnection conn = em.unwrap(Repository.class).getConnection()) { final List terms = conn.getStatements(null, SKOS.HAS_TOP_CONCEPT, null).stream() - .map(Statement::getObject).collect(Collectors.toList()); + .map(Statement::getObject).collect(Collectors.toList()); assertEquals(1, terms.size()); assertThat(terms, hasItem(vf.createIRI(Vocabulary.s_c_uzivatel_termitu))); assertFalse(conn.hasStatement(vf.createIRI(Vocabulary.s_c_uzivatel_termitu), SKOS.TOP_CONCEPT_OF, null, @@ -441,9 +467,10 @@ void importSkipsAssertedTopConceptOfStatements() { void importMovesDescriptionFromGlossaryToVocabulary() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); final Optional result = vocabularyDao.find(VOCABULARY_IRI); @@ -461,8 +488,9 @@ void importConnectsExistingAccessControlListToImportedVocabulary() { }); transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"))); }); final cz.cvut.kbss.termit.model.Vocabulary result = findVocabulary(); @@ -474,9 +502,10 @@ void importConnectsExistingAccessControlListToImportedVocabulary() { void importImportsVocabularyLabelAndDescriptionInAllDeclaredLanguages() { transactional(() -> { final SKOSImporter sut = context.getBean(SKOSImporter.class); - sut.importVocabulary(VOCABULARY_IRI, Constants.MediaType.TURTLE, persister, - Environment.loadFile("data/test-glossary.ttl"), - Environment.loadFile("data/test-vocabulary.ttl")); + sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, VOCABULARY_IRI, persister), + new VocabularyImporter.ImportInput(Constants.MediaType.TURTLE, + Environment.loadFile("data/test-glossary.ttl"), + Environment.loadFile("data/test-vocabulary.ttl"))); }); final Set languages = Set.of("en", "cs"); diff --git a/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java b/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java index ba11dc259..b5fe33c37 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java @@ -21,6 +21,7 @@ import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; @@ -36,12 +37,10 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.io.InputStream; -import java.net.URI; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -71,11 +70,12 @@ void passesInputStreamFromProvidedInputFileToImporter() throws IOException { Constants.MediaType.TURTLE, Environment.loadFile("data/test-vocabulary.ttl")); final Vocabulary vocabulary = Generator.generateVocabularyWithId(); - when(importer.importVocabulary(any(URI.class), any(), any(), any())).thenReturn(vocabulary); + when(importer.importVocabulary(any(VocabularyImporter.ImportConfiguration.class), + any(VocabularyImporter.ImportInput.class))).thenReturn(vocabulary); final Vocabulary result = sut.importVocabulary(vocabulary.getUri(), input); - final ArgumentCaptor captor = ArgumentCaptor.forClass(InputStream.class); - verify(importer).importVocabulary(eq(vocabulary.getUri()), eq(Constants.MediaType.TURTLE), any(), - captor.capture()); + final ArgumentCaptor captor = ArgumentCaptor.forClass( + VocabularyImporter.ImportInput.class); + verify(importer).importVocabulary(any(VocabularyImporter.ImportConfiguration.class), captor.capture()); assertNotNull(captor.getValue()); assertEquals(vocabulary, result); } From bf234d715a227ef4b6d8668976fa7fcf8834fa32 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Wed, 19 Jun 2024 18:23:52 +0200 Subject: [PATCH 015/150] [kbss-cvut/termit-ui#449] Resolve vocabulary importer based on provided media type. --- .../persistence/dao/skos/SKOSImporter.java | 11 +++ .../service/importer/ExcelImporter.java | 34 +++++++ .../service/importer/VocabularyImporter.java | 7 +- .../service/importer/VocabularyImporters.java | 41 +++++++++ .../VocabularyRepositoryService.java | 23 ++--- .../cz/cvut/kbss/termit/util/Constants.java | 3 + .../dao/skos/SKOSImporterTest.java | 13 +++ ...VocabularyRepositoryServiceImportTest.java | 18 +--- .../service/importer/ExcelImporterTest.java | 22 +++++ .../importer/VocabularyImportersTest.java | 89 +++++++++++++++++++ 10 files changed, 226 insertions(+), 35 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java create mode 100644 src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java create mode 100644 src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java index 514f99e05..5dd87cfef 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java @@ -28,6 +28,7 @@ import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; +import jakarta.validation.constraints.NotNull; import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.Literal; import org.eclipse.rdf4j.model.Model; @@ -361,4 +362,14 @@ private void handleGlossaryStringProperty(IRI property, Consumer prePersist) { + @NotNull Consumer prePersist) { } /** @@ -46,6 +47,6 @@ record ImportConfiguration(boolean allowReIdentify, URI vocabularyIri, * @param mediaType Media type of the imported data * @param data Streams containing the data */ - record ImportInput(String mediaType, InputStream... data) { + record ImportInput(@NotNull String mediaType, InputStream... data) { } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java new file mode 100644 index 000000000..f10a4bc3a --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java @@ -0,0 +1,41 @@ +package cz.cvut.kbss.termit.service.importer; + +import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import org.springframework.context.ApplicationContext; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +/** + * Ensures correct importer is invoked for provided media types. + */ +@Component +public class VocabularyImporters implements VocabularyImporter { + + private final ApplicationContext appContext; + + public VocabularyImporters(ApplicationContext appContext) { + this.appContext = appContext; + } + + @Override + public Vocabulary importVocabulary(@NonNull ImportConfiguration config, @NonNull ImportInput data) { + if (SKOSImporter.supportsMediaType(data.mediaType())) { + return getSkosImporter().importVocabulary(config, data); + } + if (ExcelImporter.supportsMediaType(data.mediaType())) { + return getExcelImporter().importVocabulary(config, data); + } + throw new UnsupportedImportMediaTypeException( + "Unsupported media type '" + data.mediaType() + "' for vocabulary import."); + } + + private VocabularyImporter getSkosImporter() { + return appContext.getBean(SKOSImporter.class); + } + + private VocabularyImporter getExcelImporter() { + return appContext.getBean(ExcelImporter.class); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index 6f79993ee..17d1d713b 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -32,10 +32,10 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.persistence.dao.BaseAssetDao; import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; -import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.MessageFormatter; import cz.cvut.kbss.termit.service.importer.VocabularyImporter; +import cz.cvut.kbss.termit.service.importer.VocabularyImporters; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; @@ -50,7 +50,6 @@ import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; -import org.springframework.context.ApplicationContext; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -78,31 +77,23 @@ public class VocabularyRepositoryService extends BaseAssetRepositoryService getPrimaryDao() { return vocabularyDao; @@ -231,7 +222,7 @@ public Vocabulary importVocabulary(boolean rename, MultipartFile file) { Objects.requireNonNull(file); try { String contentType = resolveContentType(file); - return getSKOSImporter().importVocabulary( + return importers.importVocabulary( new VocabularyImporter.ImportConfiguration(rename, null, this::initDocument), new VocabularyImporter.ImportInput(contentType, file.getInputStream())); } catch (VocabularyImportException e) { @@ -254,7 +245,7 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { Objects.requireNonNull(file); try { String contentType = resolveContentType(file); - return getSKOSImporter().importVocabulary( + return importers.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabularyIri, this::initDocument), new VocabularyImporter.ImportInput(contentType, file.getInputStream())); } catch (VocabularyImportException e) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index c33e0969c..e493813bc 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -193,6 +193,9 @@ private RDFa() { * Additional media types not covered by {@link org.springframework.http.MediaType}. */ public static final class MediaType { + /** + * Media type for .xlsx + */ public static final String EXCEL = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; public static final String TURTLE = "text/turtle"; public static final String RDF_XML = "application/rdf+xml"; diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java index 88c9e20be..b4ee9f593 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporterTest.java @@ -44,6 +44,8 @@ import org.eclipse.rdf4j.repository.RepositoryConnection; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.test.annotation.DirtiesContext; @@ -516,4 +518,15 @@ void importImportsVocabularyLabelAndDescriptionInAllDeclaredLanguages() { assertThat(result.get().getDescription().get(lang), not(emptyOrNullString())); }); } + + @ParameterizedTest + @CsvSource({Constants.MediaType.TURTLE, Constants.MediaType.RDF_XML, "application/n-triples"}) + void supportsMediaTypeReturnsTrueForSupportedRDFBasedMediaTypes(String mediaType) { + assertTrue(SKOSImporter.supportsMediaType(mediaType)); + } + + @Test + void supportsMediaTypeReturnsFalseForUnsupportedMediaType() { + assertFalse(SKOSImporter.supportsMediaType(Constants.MediaType.EXCEL)); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java b/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java index b5fe33c37..276a146a9 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/VocabularyRepositoryServiceImportTest.java @@ -20,19 +20,16 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; import cz.cvut.kbss.termit.service.importer.VocabularyImporter; +import cz.cvut.kbss.termit.service.importer.VocabularyImporters; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; -import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationContext; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; @@ -48,22 +45,11 @@ class VocabularyRepositoryServiceImportTest { @Mock - private SKOSImporter importer; - - @Mock - private ApplicationContext context; - - @Mock - private Configuration configuration; + private VocabularyImporters importer; @InjectMocks private VocabularyRepositoryService sut; - @BeforeEach - void setUp() { - when(context.getBean(SKOSImporter.class)).thenReturn(importer); - } - @Test void passesInputStreamFromProvidedInputFileToImporter() throws IOException { final MultipartFile input = new MockMultipartFile("vocabulary.ttl", "vocabulary.ttl", diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java new file mode 100644 index 000000000..959707718 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java @@ -0,0 +1,22 @@ +package cz.cvut.kbss.termit.service.importer; + +import cz.cvut.kbss.termit.util.Constants; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.*; + +class ExcelImporterTest { + + @ParameterizedTest + @CsvSource({Constants.MediaType.EXCEL, "application/vnd.ms-excel"}) + void supportsMediaTypeReturnsTrueForSupportedExcelMediaType(String mediaType) { + assertTrue(ExcelImporter.supportsMediaType(mediaType)); + } + + @Test + void supportsMediaTypeReturnsFalseForUnsupportedMediaType() { + assertFalse(ExcelImporter.supportsMediaType("application/json")); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java new file mode 100644 index 000000000..50fef3951 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java @@ -0,0 +1,89 @@ +package cz.cvut.kbss.termit.service.importer; + +import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import cz.cvut.kbss.termit.util.Constants; +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; +import org.springframework.context.ApplicationContext; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VocabularyImportersTest { + + @Mock + private ExcelImporter excelImporter; + + @Mock + private SKOSImporter skosImporter; + + @Mock + private ApplicationContext appContext; + + @InjectMocks + private VocabularyImporters sut; + + private final Vocabulary importedVocabulary = Generator.generateVocabularyWithId(); + + @Test + void importVocabularyInvokesSkosImporterForRdfSkosInput() { + when(appContext.getBean(SKOSImporter.class)).thenReturn(skosImporter); + when(skosImporter.importVocabulary(any(), any())).thenReturn(importedVocabulary); + final VocabularyImporter.ImportConfiguration importConfig = new VocabularyImporter.ImportConfiguration(false, + Generator.generateUri(), + mock(Consumer.class)); + final VocabularyImporter.ImportInput importInput = new VocabularyImporter.ImportInput( + Constants.MediaType.TURTLE, new ByteArrayInputStream("data".getBytes( + StandardCharsets.UTF_8))); + final Vocabulary result = sut.importVocabulary(importConfig, importInput); + assertEquals(importedVocabulary, result); + verify(skosImporter).importVocabulary(importConfig, importInput); + } + + @Test + void importVocabularyInvokesExcelImporterForExcelInput() { + when(appContext.getBean(ExcelImporter.class)).thenReturn(excelImporter); + when(excelImporter.importVocabulary(any(), any())).thenReturn(importedVocabulary); + final VocabularyImporter.ImportConfiguration importConfig = new VocabularyImporter.ImportConfiguration(false, + Generator.generateUri(), + mock(Consumer.class)); + final VocabularyImporter.ImportInput importInput = new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + new ByteArrayInputStream( + "data".getBytes( + StandardCharsets.UTF_8))); + final Vocabulary result = sut.importVocabulary(importConfig, importInput); + assertEquals(importedVocabulary, result); + verify(excelImporter).importVocabulary(importConfig, importInput); + } + + @Test + void importVocabularyThrowsUnsupportedImportMediaTypeExceptionForUnsupportedMediaType() { + final VocabularyImporter.ImportConfiguration importConfig = new VocabularyImporter.ImportConfiguration(false, + Generator.generateUri(), + mock(Consumer.class)); + final VocabularyImporter.ImportInput importInput = new VocabularyImporter.ImportInput("text/csv", + new ByteArrayInputStream( + "data".getBytes( + StandardCharsets.UTF_8))); + + assertThrows(UnsupportedImportMediaTypeException.class, () -> sut.importVocabulary(importConfig, importInput)); + verify(skosImporter, never()).importVocabulary(any(), any()); + verify(excelImporter, never()).importVocabulary(any(), any()); + } +} From 7a369ca3c430fa96417b6b0b3d3b8438d80fde8a Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 25 Jun 2024 14:46:09 +0200 Subject: [PATCH 016/150] [kbss-cvut/termit-ui#449] Implement import of basic attributes from Excel. --- .../VocabularyDoesNotExistException.java | 10 ++ .../importing/VocabularyImportException.java | 5 + .../rest/handler/RestExceptionHandler.java | 2 +- .../service/importer/ExcelImporter.java | 34 ----- .../service/importer/VocabularyImporters.java | 1 + .../service/importer/excel/ExcelImporter.java | 80 ++++++++++ .../excel/LocalizedSheetImporter.java | 114 ++++++++++++++ .../cz/cvut/kbss/termit/util/Constants.java | 1 + src/main/resources/attributes/cs.properties | 9 ++ src/main/resources/attributes/en.properties | 11 ++ .../service/importer/ExcelImporterTest.java | 22 --- .../importer/VocabularyImportersTest.java | 1 + .../importer/excel/ExcelImporterTest.java | 143 ++++++++++++++++++ 13 files changed, 376 insertions(+), 57 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyDoesNotExistException.java delete mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java create mode 100644 src/main/resources/attributes/cs.properties create mode 100644 src/main/resources/attributes/en.properties delete mode 100644 src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java create mode 100644 src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java diff --git a/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyDoesNotExistException.java b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyDoesNotExistException.java new file mode 100644 index 000000000..947ef0784 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyDoesNotExistException.java @@ -0,0 +1,10 @@ +package cz.cvut.kbss.termit.exception.importing; + +/** + * Indicates that an existing vocabulary was expected for import but none was found. + */ +public class VocabularyDoesNotExistException extends VocabularyImportException { + public VocabularyDoesNotExistException(String message) { + super(message); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java index 68a81ce84..9a0dbca94 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java @@ -36,6 +36,11 @@ public VocabularyImportException(String message, String messageId) { this.messageId = messageId; } + public VocabularyImportException(String message, Throwable cause) { + super(message, cause); + this.messageId = null; + } + public String getMessageId() { return messageId; } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index 6e8d9b99e..c17a253d1 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -228,7 +228,7 @@ public ResponseEntity invalidLanguageConstantException(HttpServletReq } @ExceptionHandler - public ResponseEntity vocabularyImportException(HttpServletRequest request, + public ResponseEntity invalidTermStateException(HttpServletRequest request, InvalidTermStateException e) { logException(e, request); return new ResponseEntity<>( diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java deleted file mode 100644 index bc0d66da7..000000000 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/ExcelImporter.java +++ /dev/null @@ -1,34 +0,0 @@ -package cz.cvut.kbss.termit.service.importer; - -import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.util.Constants; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; -import org.springframework.context.annotation.Scope; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; - -@Component -@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) -public class ExcelImporter implements VocabularyImporter { - - /** - * Media type for legacy .xls files. - */ - private static final String XLS_MEDIA_TYPE = "application/vnd.ms-excel"; - - @Override - public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) { - // TODO - return null; - } - - /** - * Checks whether this importer supports the specified media type. - * - * @param mediaType Media type to check - * @return {@code true} when media type is supported, {@code false} otherwise - */ - public static boolean supportsMediaType(@NonNull String mediaType) { - return Constants.MediaType.EXCEL.equals(mediaType) || XLS_MEDIA_TYPE.equals(mediaType); - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java index f10a4bc3a..d2782a9f1 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java @@ -3,6 +3,7 @@ import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import cz.cvut.kbss.termit.service.importer.excel.ExcelImporter; import org.springframework.context.ApplicationContext; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java new file mode 100644 index 000000000..97ebfc8f0 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -0,0 +1,80 @@ +package cz.cvut.kbss.termit.service.importer.excel; + +import cz.cvut.kbss.termit.exception.NotFoundException; +import cz.cvut.kbss.termit.exception.importing.VocabularyDoesNotExistException; +import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; +import cz.cvut.kbss.termit.model.Term; +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; +import cz.cvut.kbss.termit.service.repository.TermRepositoryService; +import cz.cvut.kbss.termit.util.Constants; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class ExcelImporter implements VocabularyImporter { + + /** + * Media type for legacy .xls files. + */ + private static final String XLS_MEDIA_TYPE = "application/vnd.ms-excel"; + + private final VocabularyDao vocabularyDao; + + private final TermRepositoryService termService; + + public ExcelImporter(VocabularyDao vocabularyDao, TermRepositoryService termService) { + this.vocabularyDao = vocabularyDao; + this.termService = termService; + } + + @Override + public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) { + Objects.requireNonNull(config); + Objects.requireNonNull(data); + if (config.vocabularyIri() == null || !vocabularyDao.exists(config.vocabularyIri())) { + throw new VocabularyDoesNotExistException("An existing vocabulary must be specified for Excel import."); + } + final Vocabulary targetVocabulary = vocabularyDao.find(config.vocabularyIri()).orElseThrow(() -> NotFoundException.create(Vocabulary.class, config.vocabularyIri())); + try { + for (InputStream input : data.data()) { + final Workbook workbook = new XSSFWorkbook(input); + assert workbook.getNumberOfSheets() > 0; + final Sheet sheet = workbook.getSheetAt(0); + // TODO Reuse terms between internationalized sheets + final List terms = new LocalizedSheetImporter().resolveTermsFromSheet(sheet); + // TODO Parents vs children + terms.forEach(t -> termService.addRootTermToVocabulary(t, targetVocabulary)); + } + } catch (IOException e) { + throw new VocabularyImportException("Unable to read input as Excel.", e); + } + return targetVocabulary; + } + + /** + * Checks whether this importer supports the specified media type. + * + * @param mediaType Media type to check + * @return {@code true} when media type is supported, {@code false} otherwise + */ + public static boolean supportsMediaType(@NonNull String mediaType) { + return Constants.MediaType.EXCEL.equals(mediaType) || XLS_MEDIA_TYPE.equals(mediaType); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java new file mode 100644 index 000000000..774645061 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -0,0 +1,114 @@ +package cz.cvut.kbss.termit.service.importer.excel; + +import com.neovisionaries.i18n.LanguageCode; +import cz.cvut.kbss.jopa.model.MultilingualString; +import cz.cvut.kbss.jopa.vocabulary.DC; +import cz.cvut.kbss.jopa.vocabulary.SKOS; +import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; +import cz.cvut.kbss.termit.model.Term; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +class LocalizedSheetImporter { + + private static final Logger LOG = LoggerFactory.getLogger(LocalizedSheetImporter.class); + + private Map attributeToColumn; + + List resolveTermsFromSheet(Sheet sheet) { + LOG.debug("Importing terms from sheet '{}'.", sheet.getSheetName()); + final LanguageCode lang = resolveLanguage(sheet); + final String langTag = lang.name(); + LOG.trace("Sheet '{}' mapped to language tage '{}'.", sheet.getSheetName(), langTag); + final Properties attributeMapping = new Properties(); + final Map labelToTerm = new LinkedHashMap<>(); + try { + attributeMapping.load( + getClass().getClassLoader().getResourceAsStream("attributes/" + langTag + ".properties")); + final Row attributes = sheet.getRow(0); + this.attributeToColumn = resolveAttributeColumns(attributes, attributeMapping); + } catch (IOException e) { + LOG.error("Unable to find attribute mapping for sheet {}. Skipping the sheet.", sheet.getSheetName(), e); + return Collections.emptyList(); + } + for (int i = 1; i < sheet.getLastRowNum(); i++) { + final Row termRow = sheet.getRow(i); + final Term term = new Term(); + final Optional label = getAttributeValue(termRow, SKOS.PREF_LABEL); + if (label.isEmpty()) { + LOG.trace("Reached empty label column cell at row {}. Finished processing sheet.", i); + break; + } + term.setLabel(MultilingualString.create(label.get(), langTag)); + getAttributeValue(termRow, SKOS.DEFINITION).ifPresent( + d -> term.setDefinition(MultilingualString.create(d, langTag))); + getAttributeValue(termRow, SKOS.SCOPE_NOTE).ifPresent( + sn -> term.setDescription(MultilingualString.create(sn, langTag))); + getAttributeValue(termRow, SKOS.ALT_LABEL).ifPresent(al -> term.setAltLabels( + splitIntoMultipleValues(al).stream().map(s -> MultilingualString.create(s, langTag)).collect( + Collectors.toSet()))); + getAttributeValue(termRow, SKOS.HIDDEN_LABEL).ifPresent(hl -> term.setHiddenLabels( + splitIntoMultipleValues(hl).stream().map(s -> MultilingualString.create(s, langTag)).collect( + Collectors.toSet()))); + getAttributeValue(termRow, SKOS.EXAMPLE).ifPresent(ex -> term.setExamples( + splitIntoMultipleValues(ex).stream().map(s -> MultilingualString.create(s, langTag)).collect( + Collectors.toSet()))); + getAttributeValue(termRow, DC.Terms.SOURCE).ifPresent(src -> term.setSources(splitIntoMultipleValues(src))); + getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); + getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( + nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); + labelToTerm.put(label.get(), term); + } + return new ArrayList<>(labelToTerm.values()); + } + + private static LanguageCode resolveLanguage(Sheet sheet) { + final List codes = LanguageCode.findByName(sheet.getSheetName()); + if (codes.isEmpty()) { + throw new VocabularyImportException("Unsupported sheet language " + sheet.getSheetName()); + } + return codes.get(0); + } + + private Map resolveAttributeColumns(Row attributes, Properties attributeMapping) { + final Map attributesToColumn = new HashMap<>(); + final Iterator it = attributes.cellIterator(); + while (it.hasNext()) { + final Cell cell = it.next(); + final String columnLabel = cell.getStringCellValue(); + for (Map.Entry e : attributeMapping.entrySet()) { + if (e.getValue().equals(columnLabel)) { + attributesToColumn.put(e.getKey().toString(), cell.getColumnIndex()); + break; + } + } + } + return attributesToColumn; + } + + private Optional getAttributeValue(Row row, String attributeIri) { + final String cellValue = row.getCell(attributeToColumn.get(attributeIri)).getStringCellValue(); + return cellValue.isBlank() ? Optional.empty() : Optional.of(cellValue.trim()); + } + + private Set splitIntoMultipleValues(String value) { + return Stream.of(value.split(",")).map(String::trim).collect(Collectors.toSet()); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index e493813bc..247a758ae 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -133,6 +133,7 @@ public class Constants { /** * Labels of columns representing exported term attributes in various supported languages. + * TODO Replace with constants loaded from attribute mapping properties files */ public static final Map> EXPORT_COLUMN_LABELS = Map.of( "cs", diff --git a/src/main/resources/attributes/cs.properties b/src/main/resources/attributes/cs.properties new file mode 100644 index 000000000..ed6b1dffb --- /dev/null +++ b/src/main/resources/attributes/cs.properties @@ -0,0 +1,9 @@ +http\://www.w3.org/2004/02/skos/core#prefLabel=Název +http\://www.w3.org/2004/02/skos/core#definition=Definice +http\://www.w3.org/2004/02/skos/core#scopeNote=Dopl?ující poznámka +http\://www.w3.org/2004/02/skos/core#altLabel=Synonyma +http\://www.w3.org/2004/02/skos/core#hiddenLabel=Vyhledávací texty +http\://www.w3.org/2004/02/skos/core#example=P?íklady +http\://www.w3.org/2004/02/skos/core#notation=Notace +http\://purl.org/dc/terms/source=Zdroj +http\://purl.org/dc/terms/references=Reference diff --git a/src/main/resources/attributes/en.properties b/src/main/resources/attributes/en.properties new file mode 100644 index 000000000..4205bca27 --- /dev/null +++ b/src/main/resources/attributes/en.properties @@ -0,0 +1,11 @@ +http\://www.w3.org/2004/02/skos/core#prefLabel=Label +http\://www.w3.org/2004/02/skos/core#definition=Definition +http\://www.w3.org/2004/02/skos/core#scopeNote=Scope note +http\://www.w3.org/2004/02/skos/core#altLabel=Synonyms +http\://www.w3.org/2004/02/skos/core#hiddenLabel=Search strings +http\://www.w3.org/2004/02/skos/core#example=Example +http\://www.w3.org/2004/02/skos/core#notation=Notation +http\://purl.org/dc/terms/source=Source +http\://purl.org/dc/terms/references=References + + diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java deleted file mode 100644 index 959707718..000000000 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/ExcelImporterTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package cz.cvut.kbss.termit.service.importer; - -import cz.cvut.kbss.termit.util.Constants; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -import static org.junit.jupiter.api.Assertions.*; - -class ExcelImporterTest { - - @ParameterizedTest - @CsvSource({Constants.MediaType.EXCEL, "application/vnd.ms-excel"}) - void supportsMediaTypeReturnsTrueForSupportedExcelMediaType(String mediaType) { - assertTrue(ExcelImporter.supportsMediaType(mediaType)); - } - - @Test - void supportsMediaTypeReturnsFalseForUnsupportedMediaType() { - assertFalse(ExcelImporter.supportsMediaType("application/json")); - } -} diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java index 50fef3951..acd1ceb92 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/VocabularyImportersTest.java @@ -4,6 +4,7 @@ import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; +import cz.cvut.kbss.termit.service.importer.excel.ExcelImporter; import cz.cvut.kbss.termit.util.Constants; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java new file mode 100644 index 000000000..954a23c2c --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -0,0 +1,143 @@ +package cz.cvut.kbss.termit.service.importer.excel; + +import cz.cvut.kbss.jopa.model.MultilingualString; +import cz.cvut.kbss.jopa.vocabulary.DC; +import cz.cvut.kbss.termit.environment.Environment; +import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.exception.importing.VocabularyDoesNotExistException; +import cz.cvut.kbss.termit.model.Term; +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.service.importer.VocabularyImporter; +import cz.cvut.kbss.termit.service.repository.TermRepositoryService; +import cz.cvut.kbss.termit.util.Constants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ExcelImporterTest { + + @Mock + private VocabularyDao vocabularyDao; + + @Mock + private TermRepositoryService termService; + + @Mock + private Consumer prePersist; + + @InjectMocks + private ExcelImporter sut; + + private Vocabulary vocabulary; + + @BeforeEach + void setUp() { + this.vocabulary = Generator.generateVocabularyWithId(); + } + + @ParameterizedTest + @CsvSource({Constants.MediaType.EXCEL, "application/vnd.ms-excel"}) + void supportsMediaTypeReturnsTrueForSupportedExcelMediaType(String mediaType) { + assertTrue(ExcelImporter.supportsMediaType(mediaType)); + } + + @Test + void supportsMediaTypeReturnsFalseForUnsupportedMediaType() { + assertFalse(ExcelImporter.supportsMediaType("application/json")); + } + + @Test + void importThrowsVocabularyDoesNotExistExceptionWhenNoVocabularyIdentifierIsProvided() { + assertThrows(VocabularyDoesNotExistException.class, + () -> sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, null, prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx")))); + } + + @Test + void importThrowsVocabularyDoesNotExistExceptionWhenVocabularyIsNotFound() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(false); + assertThrows(VocabularyDoesNotExistException.class, + () -> sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx")))); + } + + @Test + void importCreatesRootTermsWithBasicAttributesFromEnglishSheet() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + + final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(2, captor.getAllValues().size()); + final Optional building = captor.getAllValues().stream().filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + assertTrue(building.isPresent()); + assertEquals("Definition of term Building", building.get().getDefinition().get("en")); + assertEquals("Building scope note", building.get().getDescription().get("en")); + final Optional construction = captor.getAllValues().stream().filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + assertTrue(construction.isPresent()); + assertEquals("The process of building a building", construction.get().getDefinition().get("en")); + } + + @Test + void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + + final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-plural-atts-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(1, captor.getAllValues().size()); + final Term building = captor.getValue(); + assertEquals("Definition of term Building", building.getDefinition().get("en")); + assertEquals("Building scope note", building.getDescription().get("en")); + assertEquals(Set.of( + MultilingualString.create("Construction", "en"), + MultilingualString.create("Structure", "en"), + MultilingualString.create("House", "en") + ), building.getAltLabels()); + assertEquals(Set.of( + MultilingualString.create("bldng", "en"), + MultilingualString.create("strctr", "en"), + MultilingualString.create("haus", "en") + ), building.getHiddenLabels()); + assertEquals(Set.of( + MultilingualString.create("Dancing house", "en") + ), building.getExamples()); + assertEquals(Set.of("B"), building.getNotations()); + assertEquals(Set.of("a56"), building.getProperties().get(DC.Terms.REFERENCES)); + } +} From 733bbe48ec0e94edd5b2a2afff9871abaf3625e8 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 25 Jun 2024 16:13:37 +0200 Subject: [PATCH 017/150] [kbss-cvut/termit-ui#449] Support importing multilingual Excel without term references. --- .../service/importer/excel/ExcelImporter.java | 10 ++- .../excel/LocalizedSheetImporter.java | 87 +++++++++++++------ src/main/resources/attributes/cs.properties | 4 +- .../importer/excel/ExcelImporterTest.java | 54 ++++++++++++ 4 files changed, 125 insertions(+), 30 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index 97ebfc8f0..1ae7d2b4e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -53,12 +54,15 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) } final Vocabulary targetVocabulary = vocabularyDao.find(config.vocabularyIri()).orElseThrow(() -> NotFoundException.create(Vocabulary.class, config.vocabularyIri())); try { + List terms = Collections.emptyList(); for (InputStream input : data.data()) { final Workbook workbook = new XSSFWorkbook(input); assert workbook.getNumberOfSheets() > 0; - final Sheet sheet = workbook.getSheetAt(0); - // TODO Reuse terms between internationalized sheets - final List terms = new LocalizedSheetImporter().resolveTermsFromSheet(sheet); + for (int i = 0; i < workbook.getNumberOfSheets(); i++) { + final Sheet sheet = workbook.getSheetAt(i); + // TODO Reuse terms between internationalized sheets + terms = new LocalizedSheetImporter(terms).resolveTermsFromSheet(sheet); + } // TODO Parents vs children terms.forEach(t -> termService.addRootTermToVocabulary(t, targetVocabulary)); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 774645061..a433f4f5b 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -4,7 +4,6 @@ import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.jopa.vocabulary.SKOS; -import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; import cz.cvut.kbss.termit.model.Term; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; @@ -16,6 +15,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; @@ -23,6 +23,8 @@ import java.util.Optional; import java.util.Properties; import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -30,12 +32,22 @@ class LocalizedSheetImporter { private static final Logger LOG = LoggerFactory.getLogger(LocalizedSheetImporter.class); + private final List existingTerms; + private Map attributeToColumn; + private String langTag; + + LocalizedSheetImporter(List existingTerms) { + this.existingTerms = existingTerms; + } List resolveTermsFromSheet(Sheet sheet) { LOG.debug("Importing terms from sheet '{}'.", sheet.getSheetName()); - final LanguageCode lang = resolveLanguage(sheet); - final String langTag = lang.name(); + final Optional lang = resolveLanguage(sheet); + if (lang.isEmpty()) { + return existingTerms; + } + this.langTag = lang.get().name(); LOG.trace("Sheet '{}' mapped to language tage '{}'.", sheet.getSheetName(), langTag); final Properties attributeMapping = new Properties(); final Map labelToTerm = new LinkedHashMap<>(); @@ -50,41 +62,66 @@ List resolveTermsFromSheet(Sheet sheet) { } for (int i = 1; i < sheet.getLastRowNum(); i++) { final Row termRow = sheet.getRow(i); - final Term term = new Term(); + Term term = existingTerms.size() >= i ? existingTerms.get(i - 1) : new Term(); final Optional label = getAttributeValue(termRow, SKOS.PREF_LABEL); if (label.isEmpty()) { LOG.trace("Reached empty label column cell at row {}. Finished processing sheet.", i); break; } - term.setLabel(MultilingualString.create(label.get(), langTag)); - getAttributeValue(termRow, SKOS.DEFINITION).ifPresent( - d -> term.setDefinition(MultilingualString.create(d, langTag))); - getAttributeValue(termRow, SKOS.SCOPE_NOTE).ifPresent( - sn -> term.setDescription(MultilingualString.create(sn, langTag))); - getAttributeValue(termRow, SKOS.ALT_LABEL).ifPresent(al -> term.setAltLabels( - splitIntoMultipleValues(al).stream().map(s -> MultilingualString.create(s, langTag)).collect( - Collectors.toSet()))); - getAttributeValue(termRow, SKOS.HIDDEN_LABEL).ifPresent(hl -> term.setHiddenLabels( - splitIntoMultipleValues(hl).stream().map(s -> MultilingualString.create(s, langTag)).collect( - Collectors.toSet()))); - getAttributeValue(termRow, SKOS.EXAMPLE).ifPresent(ex -> term.setExamples( - splitIntoMultipleValues(ex).stream().map(s -> MultilingualString.create(s, langTag)).collect( - Collectors.toSet()))); - getAttributeValue(termRow, DC.Terms.SOURCE).ifPresent(src -> term.setSources(splitIntoMultipleValues(src))); - getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); - getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( - nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); + mapRowToTerm(term, label.get(), termRow); labelToTerm.put(label.get(), term); } return new ArrayList<>(labelToTerm.values()); } - private static LanguageCode resolveLanguage(Sheet sheet) { + private void mapRowToTerm(Term term, String label, Row termRow) { + initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label); + getAttributeValue(termRow, SKOS.DEFINITION).ifPresent( + d -> initSingularMultilingualString(term::getDefinition, term::setDefinition).set(langTag, d)); + getAttributeValue(termRow, SKOS.SCOPE_NOTE).ifPresent( + sn -> initSingularMultilingualString(term::getDescription, term::setDescription).set(langTag, sn)); + getAttributeValue(termRow, SKOS.ALT_LABEL).ifPresent(al -> populatePluralMultilingualString(term::getAltLabels, term::setAltLabels, splitIntoMultipleValues(al))); + getAttributeValue(termRow, SKOS.HIDDEN_LABEL).ifPresent(hl -> populatePluralMultilingualString(term::getHiddenLabels, term::setHiddenLabels, splitIntoMultipleValues(hl))); + getAttributeValue(termRow, SKOS.EXAMPLE).ifPresent(ex -> populatePluralMultilingualString(term::getExamples, term::setExamples, splitIntoMultipleValues(ex))); + getAttributeValue(termRow, DC.Terms.SOURCE).ifPresent(src -> term.setSources(splitIntoMultipleValues(src))); + getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); + getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( + nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); + } + + private MultilingualString initSingularMultilingualString(Supplier getter, + Consumer setter) { + if (getter.get() == null) { + setter.accept(new MultilingualString()); + } + return getter.get(); + } + + private void populatePluralMultilingualString( + Supplier> getter, Consumer> setter, Set values) { + Set attValue = getter.get(); + if (attValue == null) { + setter.accept(new HashSet<>()); + attValue = getter.get(); + } + for (String s : values) { + final Optional mls = attValue.stream().filter(m -> !m.contains(langTag)).findFirst(); + if (mls.isPresent()) { + mls.get().set(langTag, s); + } else { + final MultilingualString newMls = MultilingualString.create(s, langTag); + attValue.add(newMls); + } + } + } + + private static Optional resolveLanguage(Sheet sheet) { final List codes = LanguageCode.findByName(sheet.getSheetName()); if (codes.isEmpty()) { - throw new VocabularyImportException("Unsupported sheet language " + sheet.getSheetName()); + LOG.debug("No matching language found for sheet '{}'. Skipping it.", sheet.getSheetName()); + return Optional.empty(); } - return codes.get(0); + return Optional.of(codes.get(0)); } private Map resolveAttributeColumns(Row attributes, Properties attributeMapping) { diff --git a/src/main/resources/attributes/cs.properties b/src/main/resources/attributes/cs.properties index ed6b1dffb..1172baa56 100644 --- a/src/main/resources/attributes/cs.properties +++ b/src/main/resources/attributes/cs.properties @@ -1,9 +1,9 @@ http\://www.w3.org/2004/02/skos/core#prefLabel=Název http\://www.w3.org/2004/02/skos/core#definition=Definice -http\://www.w3.org/2004/02/skos/core#scopeNote=Dopl?ující poznámka +http\://www.w3.org/2004/02/skos/core#scopeNote=Dopl\u0148ující poznámka http\://www.w3.org/2004/02/skos/core#altLabel=Synonyma http\://www.w3.org/2004/02/skos/core#hiddenLabel=Vyhledávací texty -http\://www.w3.org/2004/02/skos/core#example=P?íklady +http\://www.w3.org/2004/02/skos/core#example=P\u0159íklady http\://www.w3.org/2004/02/skos/core#notation=Notace http\://purl.org/dc/terms/source=Zdroj http\://purl.org/dc/terms/references=Reference diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 954a23c2c..03909a118 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -140,4 +140,58 @@ void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { assertEquals(Set.of("B"), building.getNotations()); assertEquals(Set.of("a56"), building.getProperties().get(DC.Terms.REFERENCES)); } + + @Test + void importCreatesRootTermsWithBasicAttributesFromMultipleTranslationSheets() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + + final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en-cs.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(2, captor.getAllValues().size()); + final Optional building = captor.getAllValues().stream().filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + assertTrue(building.isPresent()); + assertEquals("Budova", building.get().getLabel().get("cs")); + assertEquals("Definition of term Building", building.get().getDefinition().get("en")); + assertEquals("Definice pojmu budova", building.get().getDefinition().get("cs")); + assertEquals("Building scope note", building.get().getDescription().get("en")); + assertEquals("Doplňující poznámka pojmu budova", building.get().getDescription().get("cs")); + final Optional construction = captor.getAllValues().stream().filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + assertTrue(construction.isPresent()); + assertEquals("Stavba", construction.get().getLabel().get("cs")); + assertEquals("The process of building a building", construction.get().getDefinition().get("en")); + assertEquals("Proces výstavby budovy", construction.get().getDefinition().get("cs")); + } + + @Test + void importCreatesRootTermsWithPluralBasicAttributesFromMultipleTranslationSheets() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + + final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-plural-atts-en-cs.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(1, captor.getAllValues().size()); + final Term building = captor.getValue(); + assertEquals("Budova", building.getLabel().get("cs")); + assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("Structure"))); + assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("House"))); + assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("dům"))); + assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("stavba"))); + assertTrue(building.getHiddenLabels().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("bldng"))); + assertTrue(building.getHiddenLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("barák"))); + assertTrue(building.getExamples().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("Dancing house"))); + assertTrue(building.getExamples().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("TanÄící dům"))); + assertEquals(Set.of("B"), building.getNotations()); + assertEquals(Set.of("a56"), building.getProperties().get(DC.Terms.REFERENCES)); + } } From 905a370996f3e3f943a350151732eac86cdee817 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 25 Jun 2024 16:24:55 +0200 Subject: [PATCH 018/150] [kbss-cvut/termit-ui#449] Fix properties file encoding issues. --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index 302be74ac..d8938106a 100644 --- a/pom.xml +++ b/pom.xml @@ -564,6 +564,7 @@ copy-resources + ISO-8859-1 xlsx From e0ed345b57145fb7da1b7d6458870a15817db2ed Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 25 Jul 2024 13:01:02 +0200 Subject: [PATCH 019/150] [Fix] Add missing test Excel files. --- .../service/importer/excel/ExcelImporter.java | 1 - .../importer/excel/LocalizedSheetImporter.java | 2 +- .../kbss/termit/environment/Environment.java | 1 - .../resources/data/import-simple-en-cs.xlsx | Bin 0 -> 65487 bytes src/test/resources/data/import-simple-en.xlsx | Bin 0 -> 35551 bytes .../data/import-with-plural-atts-en-cs.xlsx | Bin 0 -> 65549 bytes .../data/import-with-plural-atts-en.xlsx | Bin 0 -> 35618 bytes 7 files changed, 1 insertion(+), 3 deletions(-) create mode 100644 src/test/resources/data/import-simple-en-cs.xlsx create mode 100644 src/test/resources/data/import-simple-en.xlsx create mode 100644 src/test/resources/data/import-with-plural-atts-en-cs.xlsx create mode 100644 src/test/resources/data/import-with-plural-atts-en.xlsx diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index 1ae7d2b4e..a0d47a9d9 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -60,7 +60,6 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) assert workbook.getNumberOfSheets() > 0; for (int i = 0; i < workbook.getNumberOfSheets(); i++) { final Sheet sheet = workbook.getSheetAt(i); - // TODO Reuse terms between internationalized sheets terms = new LocalizedSheetImporter(terms).resolveTermsFromSheet(sheet); } // TODO Parents vs children diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index a433f4f5b..8c96146ee 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -48,7 +48,7 @@ List resolveTermsFromSheet(Sheet sheet) { return existingTerms; } this.langTag = lang.get().name(); - LOG.trace("Sheet '{}' mapped to language tage '{}'.", sheet.getSheetName(), langTag); + LOG.trace("Sheet '{}' mapped to language tag '{}'.", sheet.getSheetName(), langTag); final Properties attributeMapping = new Properties(); final Map labelToTerm = new LinkedHashMap<>(); try { diff --git a/src/test/java/cz/cvut/kbss/termit/environment/Environment.java b/src/test/java/cz/cvut/kbss/termit/environment/Environment.java index 3b76b438f..a19c77b98 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/Environment.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/Environment.java @@ -29,7 +29,6 @@ import cz.cvut.kbss.termit.model.UserAccount; import cz.cvut.kbss.termit.security.model.AuthenticationToken; import cz.cvut.kbss.termit.security.model.TermItUserDetails; -import cz.cvut.kbss.termit.service.security.SecurityUtils; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Vocabulary; diff --git a/src/test/resources/data/import-simple-en-cs.xlsx b/src/test/resources/data/import-simple-en-cs.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..902268e053f74979229f20f08ffbb85250947032 GIT binary patch literal 65487 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 skPwggzJGj!nX&n=?pz7@5(f|Me literal 0 HcmV?d00001 diff --git a/src/test/resources/data/import-with-plural-atts-en-cs.xlsx b/src/test/resources/data/import-with-plural-atts-en-cs.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..33032c2efd75529422c3ef7c777d6f9fa49dc526 GIT binary patch literal 65549 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 literal 0 HcmV?d00001 diff --git a/src/test/resources/data/import-with-plural-atts-en.xlsx b/src/test/resources/data/import-with-plural-atts-en.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..30acc97ad75808e96e86ba50c20a949f434c0aff GIT binary patch literal 35618 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 literal 0 HcmV?d00001 From c986a6c86f191c06e8b775c92a1033e7dbee7ce2 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 25 Jul 2024 13:36:26 +0200 Subject: [PATCH 020/150] [kbss-cvut/termit-ui#449] Handle term hierarchies in Excel import. --- .../service/importer/excel/ExcelImporter.java | 6 +++-- .../excel/LocalizedSheetImporter.java | 20 +++++++++++++--- src/main/resources/attributes/cs.properties | 1 + src/main/resources/attributes/en.properties | 1 + .../resources/template/termit-import.xlsx | Bin 66960 -> 65237 bytes .../importer/excel/ExcelImporterTest.java | 22 ++++++++++++++++++ .../resources/data/import-hierarchy-en.xlsx | Bin 0 -> 35542 bytes 7 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 src/test/resources/data/import-hierarchy-en.xlsx diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index a0d47a9d9..d8962e82c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -9,6 +9,7 @@ import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.util.Constants; +import cz.cvut.kbss.termit.util.Utils; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; @@ -62,8 +63,9 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) final Sheet sheet = workbook.getSheetAt(i); terms = new LocalizedSheetImporter(terms).resolveTermsFromSheet(sheet); } - // TODO Parents vs children - terms.forEach(t -> termService.addRootTermToVocabulary(t, targetVocabulary)); + // Ensure all parents are saved before we start adding children + terms.stream().filter(t -> Utils.emptyIfNull(t.getParentTerms()).isEmpty()).forEach(root -> termService.addRootTermToVocabulary(root, targetVocabulary)); + terms.stream().filter(t -> !Utils.emptyIfNull(t.getParentTerms()).isEmpty()).forEach(t -> termService.addChildTerm(t, t.getParentTerms().iterator().next())); } } catch (IOException e) { throw new VocabularyImportException("Unable to read input as Excel.", e); diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 8c96146ee..4cfe8616f 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -37,6 +37,8 @@ class LocalizedSheetImporter { private Map attributeToColumn; private String langTag; + private Map labelToTerm; + LocalizedSheetImporter(List existingTerms) { this.existingTerms = existingTerms; } @@ -50,7 +52,7 @@ List resolveTermsFromSheet(Sheet sheet) { this.langTag = lang.get().name(); LOG.trace("Sheet '{}' mapped to language tag '{}'.", sheet.getSheetName(), langTag); final Properties attributeMapping = new Properties(); - final Map labelToTerm = new LinkedHashMap<>(); + this.labelToTerm = new LinkedHashMap<>(); try { attributeMapping.load( getClass().getClassLoader().getResourceAsStream("attributes/" + langTag + ".properties")); @@ -84,6 +86,7 @@ private void mapRowToTerm(Term term, String label, Row termRow) { getAttributeValue(termRow, SKOS.HIDDEN_LABEL).ifPresent(hl -> populatePluralMultilingualString(term::getHiddenLabels, term::setHiddenLabels, splitIntoMultipleValues(hl))); getAttributeValue(termRow, SKOS.EXAMPLE).ifPresent(ex -> populatePluralMultilingualString(term::getExamples, term::setExamples, splitIntoMultipleValues(ex))); getAttributeValue(termRow, DC.Terms.SOURCE).ifPresent(src -> term.setSources(splitIntoMultipleValues(src))); + getAttributeValue(termRow, SKOS.BROADER).ifPresent(br -> setParentTerms(term, splitIntoMultipleValues(br))); getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); @@ -115,6 +118,17 @@ private void populatePluralMultilingualString( } } + private void setParentTerms(Term term, Set parentLabels) { + parentLabels.forEach(label -> { + final Term parent = labelToTerm.get(label); + if (parent == null) { + LOG.warn("No parent term with label '{}' for term '{}'.", label, term.getLabel().get(langTag)); + } else { + term.addParentTerm(parent); + } + }); + } + private static Optional resolveLanguage(Sheet sheet) { final List codes = LanguageCode.findByName(sheet.getSheetName()); if (codes.isEmpty()) { @@ -124,7 +138,7 @@ private static Optional resolveLanguage(Sheet sheet) { return Optional.of(codes.get(0)); } - private Map resolveAttributeColumns(Row attributes, Properties attributeMapping) { + private static Map resolveAttributeColumns(Row attributes, Properties attributeMapping) { final Map attributesToColumn = new HashMap<>(); final Iterator it = attributes.cellIterator(); while (it.hasNext()) { @@ -145,7 +159,7 @@ private Optional getAttributeValue(Row row, String attributeIri) { return cellValue.isBlank() ? Optional.empty() : Optional.of(cellValue.trim()); } - private Set splitIntoMultipleValues(String value) { + private static Set splitIntoMultipleValues(String value) { return Stream.of(value.split(",")).map(String::trim).collect(Collectors.toSet()); } } diff --git a/src/main/resources/attributes/cs.properties b/src/main/resources/attributes/cs.properties index 1172baa56..a69ff48d1 100644 --- a/src/main/resources/attributes/cs.properties +++ b/src/main/resources/attributes/cs.properties @@ -6,4 +6,5 @@ http\://www.w3.org/2004/02/skos/core#hiddenLabel=Vyhled http\://www.w3.org/2004/02/skos/core#example=P\u0159íklady http\://www.w3.org/2004/02/skos/core#notation=Notace http\://purl.org/dc/terms/source=Zdroj +http\://www.w3.org/2004/02/skos/core#broader=Nad\u0159azené pojmy http\://purl.org/dc/terms/references=Reference diff --git a/src/main/resources/attributes/en.properties b/src/main/resources/attributes/en.properties index 4205bca27..45c227e88 100644 --- a/src/main/resources/attributes/en.properties +++ b/src/main/resources/attributes/en.properties @@ -6,6 +6,7 @@ http\://www.w3.org/2004/02/skos/core#hiddenLabel=Search strings http\://www.w3.org/2004/02/skos/core#example=Example http\://www.w3.org/2004/02/skos/core#notation=Notation http\://purl.org/dc/terms/source=Source +http\://www.w3.org/2004/02/skos/core#broader=Parent terms http\://purl.org/dc/terms/references=References diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx index fb1fab8261127be534fc916b5fcd71ae07f0c52f..2a71b6e94df6d1bc10ec87ea5eb0695004b361ab 100644 GIT binary patch literal 65237 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 rootCaptor = ArgumentCaptor.forClass(Term.class); + verify(termService).addRootTermToVocabulary(rootCaptor.capture(), eq(vocabulary)); + assertEquals(1, rootCaptor.getAllValues().size()); + final Term area = rootCaptor.getValue(); + assertEquals("Area", area.getLabel().get("en")); + final ArgumentCaptor childCaptor = ArgumentCaptor.forClass(Term.class); + verify(termService).addChildTerm(childCaptor.capture(), eq(area)); + assertEquals(1, childCaptor.getAllValues().size()); + final Term buildableArea = childCaptor.getValue(); + assertEquals("Buildable area", buildableArea.getLabel().get("en")); + } } diff --git a/src/test/resources/data/import-hierarchy-en.xlsx b/src/test/resources/data/import-hierarchy-en.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..abcb36b829d6a009a4c0e85f5e42362907e9f1d3 GIT binary patch literal 35542 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 Vn+2Rb5wUYb-hwz{1y6kU{{U&PwE_SD literal 0 HcmV?d00001 From 4edcde2233dc6c177fb37ac45c0af91b97cb51fc Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 25 Jul 2024 15:06:13 +0200 Subject: [PATCH 021/150] [kbss-cvut/termit-ui#449] Handle term relationships within a vocabulary by inserting raw data into the repository. --- .../kbss/termit/persistence/dao/DataDao.java | 68 ++++++-- .../termit/persistence/dao/util/Quad.java | 6 + .../service/importer/excel/ExcelImporter.java | 45 ++++-- .../excel/LocalizedSheetImporter.java | 78 ++++++++-- src/main/resources/attributes/cs.properties | 1 + src/main/resources/attributes/en.properties | 1 + .../termit/persistence/dao/DataDaoTest.java | 37 ++++- .../importer/excel/ExcelImporterTest.java | 145 +++++++++++++----- 8 files changed, 305 insertions(+), 76 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/persistence/dao/util/Quad.java diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java index ded878cee..d6b24fa99 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java @@ -23,14 +23,17 @@ import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.jopa.vocabulary.RDF; import cz.cvut.kbss.jopa.vocabulary.RDFS; +import cz.cvut.kbss.ontodriver.rdf4j.util.Rdf4jUtils; import cz.cvut.kbss.termit.dto.RdfsResource; import cz.cvut.kbss.termit.exception.PersistenceException; +import cz.cvut.kbss.termit.persistence.dao.util.Quad; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Configuration.Persistence; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.eclipse.rdf4j.model.Resource; +import org.eclipse.rdf4j.model.Value; import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.repository.RepositoryConnection; import org.eclipse.rdf4j.rio.RDFFormat; @@ -41,7 +44,14 @@ import java.io.ByteArrayOutputStream; import java.net.URI; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; @Repository @@ -66,14 +76,14 @@ public DataDao(EntityManager em, Configuration config) { */ public List findAllProperties() { final List result = em.createNativeQuery("SELECT ?x ?label ?comment ?type WHERE {" + - "BIND (?property as ?type)" + - "?x a ?type ." + - "OPTIONAL { ?x ?has-label ?label . }" + - "OPTIONAL { ?x ?has-comment ?comment . }" + - "}", "RdfsResource") - .setParameter("property", URI.create(RDF.PROPERTY)) - .setParameter("has-label", RDFS_LABEL) - .setParameter("has-comment", URI.create(RDFS.COMMENT)).getResultList(); + "BIND (?property as ?type)" + + "?x a ?type ." + + "OPTIONAL { ?x ?has-label ?label . }" + + "OPTIONAL { ?x ?has-comment ?comment . }" + + "}", "RdfsResource") + .setParameter("property", URI.create(RDF.PROPERTY)) + .setParameter("has-label", RDFS_LABEL) + .setParameter("has-comment", URI.create(RDFS.COMMENT)).getResultList(); return consolidateTranslations(result); } @@ -120,14 +130,15 @@ public void persist(RdfsResource instance) { */ public Optional find(URI id) { Objects.requireNonNull(id); - final List resources = consolidateTranslations(em.createNativeQuery("SELECT ?x ?label ?comment ?type WHERE {" + - "BIND (?id AS ?x)" + - "?x a ?type ." + - "OPTIONAL { ?x ?has-label ?label .}" + - "OPTIONAL { ?x ?has-comment ?comment . }" + - "}", "RdfsResource").setParameter("id", id) - .setParameter("has-label", RDFS_LABEL) - .setParameter("has-comment", URI.create(RDFS.COMMENT)).getResultList()); + final List resources = consolidateTranslations( + em.createNativeQuery("SELECT ?x ?label ?comment ?type WHERE {" + + "BIND (?id AS ?x)" + + "?x a ?type ." + + "OPTIONAL { ?x ?has-label ?label .}" + + "OPTIONAL { ?x ?has-comment ?comment . }" + + "}", "RdfsResource").setParameter("id", id) + .setParameter("has-label", RDFS_LABEL) + .setParameter("has-comment", URI.create(RDFS.COMMENT)).getResultList()); if (resources.isEmpty()) { return Optional.empty(); } @@ -183,4 +194,27 @@ public TypeAwareResource exportDataAsTurtle(URI... contexts) { ExportFormat.TURTLE.getFileExtension()); } } + + /** + * Inserts the specified raw data into the repository. + *

+ * This method allows bypassing the JOPA-based persistence layer and thus should be used very carefully and + * sparsely. + * + * @param data Data to insert + */ + public void insertRawData(Collection data) { + Objects.requireNonNull(data); + final org.eclipse.rdf4j.repository.Repository repo = em.unwrap(org.eclipse.rdf4j.repository.Repository.class); + try (final RepositoryConnection con = repo.getConnection()) { + final ValueFactory vf = con.getValueFactory(); + data.forEach(quad -> { + Value v = quad.object() instanceof URI ? vf.createIRI(quad.object().toString()) : + Rdf4jUtils.createLiteral(quad.object(), config.getLanguage(), vf); + + con.add(vf.createIRI(quad.subject().toString()), vf.createIRI(quad.predicate().toString()), v, + quad.context() != null ? vf.createIRI(quad.context().toString()) : null); + }); + } + } } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/Quad.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/Quad.java new file mode 100644 index 000000000..68bacd2d6 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/Quad.java @@ -0,0 +1,6 @@ +package cz.cvut.kbss.termit.persistence.dao.util; + +import java.net.URI; + +public record Quad(URI subject, URI predicate, Object object, URI context) { +} diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index d8962e82c..2eb8d2bf0 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -5,12 +5,13 @@ import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.DataDao; import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.persistence.dao.util.Quad; import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Utils; -import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook; @@ -21,12 +22,12 @@ import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; +import java.net.URI; import java.util.Collections; -import java.util.LinkedHashMap; +import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Objects; +import java.util.Set; @Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) @@ -40,10 +41,12 @@ public class ExcelImporter implements VocabularyImporter { private final VocabularyDao vocabularyDao; private final TermRepositoryService termService; + private final DataDao dataDao; - public ExcelImporter(VocabularyDao vocabularyDao, TermRepositoryService termService) { + public ExcelImporter(VocabularyDao vocabularyDao, TermRepositoryService termService, DataDao dataDao) { this.vocabularyDao = vocabularyDao; this.termService = termService; + this.dataDao = dataDao; } @Override @@ -53,19 +56,30 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) if (config.vocabularyIri() == null || !vocabularyDao.exists(config.vocabularyIri())) { throw new VocabularyDoesNotExistException("An existing vocabulary must be specified for Excel import."); } - final Vocabulary targetVocabulary = vocabularyDao.find(config.vocabularyIri()).orElseThrow(() -> NotFoundException.create(Vocabulary.class, config.vocabularyIri())); + final Vocabulary targetVocabulary = vocabularyDao.find(config.vocabularyIri()).orElseThrow( + () -> NotFoundException.create(Vocabulary.class, config.vocabularyIri())); try { List terms = Collections.emptyList(); + Set rawDataToInsert = new HashSet<>(); for (InputStream input : data.data()) { final Workbook workbook = new XSSFWorkbook(input); assert workbook.getNumberOfSheets() > 0; for (int i = 0; i < workbook.getNumberOfSheets(); i++) { final Sheet sheet = workbook.getSheetAt(i); - terms = new LocalizedSheetImporter(terms).resolveTermsFromSheet(sheet); + final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter(terms); + terms = sheetImporter.resolveTermsFromSheet(sheet); + rawDataToInsert.addAll(sheetImporter.getRawDataToInsert()); } // Ensure all parents are saved before we start adding children - terms.stream().filter(t -> Utils.emptyIfNull(t.getParentTerms()).isEmpty()).forEach(root -> termService.addRootTermToVocabulary(root, targetVocabulary)); - terms.stream().filter(t -> !Utils.emptyIfNull(t.getParentTerms()).isEmpty()).forEach(t -> termService.addChildTerm(t, t.getParentTerms().iterator().next())); + terms.stream().filter(t -> Utils.emptyIfNull(t.getParentTerms()).isEmpty()) + .forEach(root -> termService.addRootTermToVocabulary(root, targetVocabulary)); + terms.stream().filter(t -> !Utils.emptyIfNull(t.getParentTerms()).isEmpty()) + .forEach(t -> termService.addChildTerm(t, t.getParentTerms().iterator().next())); + // Insert term relationships as raw data because of possible object conflicts in the persistence context - + // the same term being as multiple types (Term, TermInfo) in the same persistence context + dataDao.insertRawData(rawDataToInsert.stream().map(tr -> new Quad(tr.subject().getUri(), tr.property(), + tr.object().getUri(), + targetVocabulary.getUri())).toList()); } } catch (IOException e) { throw new VocabularyImportException("Unable to read input as Excel.", e); @@ -82,4 +96,17 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) public static boolean supportsMediaType(@NonNull String mediaType) { return Constants.MediaType.EXCEL.equals(mediaType) || XLS_MEDIA_TYPE.equals(mediaType); } + + /** + * Relationship between two terms. + *

+ * Cannot use {@link Quad} directly because that the moment we are resolving the term relationships, the terms do + * not have identifiers assigned yet. + * + * @param subject Subject term + * @param property Relationships property + * @param object Object term + */ + record TermRelationship(Term subject, URI property, Term object) { + } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 4cfe8616f..a88f27f01 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -28,6 +29,11 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +/** + * Maps an Excel sheet with terms in one language to TermIt {@link Term}s, possibly reusing already processed terms. + *

+ * Note that this class keeps a state. + */ class LocalizedSheetImporter { private static final Logger LOG = LoggerFactory.getLogger(LocalizedSheetImporter.class); @@ -38,13 +44,26 @@ class LocalizedSheetImporter { private String langTag; private Map labelToTerm; + private List rawDataToInsert; LocalizedSheetImporter(List existingTerms) { this.existingTerms = existingTerms; } + /** + * Resolves terms from the specified sheet and returns them. + *

+ * If existing terms are provided to this instance, they will be reused. + *

+ * Note that relationships between terms (except hierarchical ones) are resolved separately and are available via + * {@link #getRawDataToInsert()}, whose result should be inserted directly into the repository. + * + * @param sheet Sheet to process + * @return Terms resolved from the sheet + */ List resolveTermsFromSheet(Sheet sheet) { LOG.debug("Importing terms from sheet '{}'.", sheet.getSheetName()); + this.rawDataToInsert = new ArrayList<>(); final Optional lang = resolveLanguage(sheet); if (lang.isEmpty()) { return existingTerms; @@ -62,34 +81,58 @@ List resolveTermsFromSheet(Sheet sheet) { LOG.error("Unable to find attribute mapping for sheet {}. Skipping the sheet.", sheet.getSheetName(), e); return Collections.emptyList(); } - for (int i = 1; i < sheet.getLastRowNum(); i++) { + findTerms(sheet); + int i = 1; + for (Map.Entry entry : labelToTerm.entrySet()) { + mapRowToTermAttributes(entry.getValue(), sheet.getRow(i++)); + } + return new ArrayList<>(labelToTerm.values()); + } + + /** + * First map terms to labels. + *

+ * This ensures when we are dealing with references between terms, we know we already have all the terms mapped by + * labels. + * + * @param sheet Sheet with terms + */ + private void findTerms(Sheet sheet) { + int i; + for (i = 1; i < sheet.getLastRowNum(); i++) { final Row termRow = sheet.getRow(i); Term term = existingTerms.size() >= i ? existingTerms.get(i - 1) : new Term(); final Optional label = getAttributeValue(termRow, SKOS.PREF_LABEL); if (label.isEmpty()) { - LOG.trace("Reached empty label column cell at row {}. Finished processing sheet.", i); + LOG.trace("Reached empty label column cell at row {}. Working with {} terms.", i, (i - 1)); break; } - mapRowToTerm(term, label.get(), termRow); + initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label.get()); labelToTerm.put(label.get(), term); } - return new ArrayList<>(labelToTerm.values()); + assert labelToTerm.size() == i - 1; } - private void mapRowToTerm(Term term, String label, Row termRow) { - initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label); + private void mapRowToTermAttributes(Term term, Row termRow) { getAttributeValue(termRow, SKOS.DEFINITION).ifPresent( d -> initSingularMultilingualString(term::getDefinition, term::setDefinition).set(langTag, d)); getAttributeValue(termRow, SKOS.SCOPE_NOTE).ifPresent( sn -> initSingularMultilingualString(term::getDescription, term::setDescription).set(langTag, sn)); - getAttributeValue(termRow, SKOS.ALT_LABEL).ifPresent(al -> populatePluralMultilingualString(term::getAltLabels, term::setAltLabels, splitIntoMultipleValues(al))); - getAttributeValue(termRow, SKOS.HIDDEN_LABEL).ifPresent(hl -> populatePluralMultilingualString(term::getHiddenLabels, term::setHiddenLabels, splitIntoMultipleValues(hl))); - getAttributeValue(termRow, SKOS.EXAMPLE).ifPresent(ex -> populatePluralMultilingualString(term::getExamples, term::setExamples, splitIntoMultipleValues(ex))); + getAttributeValue(termRow, SKOS.ALT_LABEL).ifPresent( + al -> populatePluralMultilingualString(term::getAltLabels, term::setAltLabels, + splitIntoMultipleValues(al))); + getAttributeValue(termRow, SKOS.HIDDEN_LABEL).ifPresent( + hl -> populatePluralMultilingualString(term::getHiddenLabels, term::setHiddenLabels, + splitIntoMultipleValues(hl))); + getAttributeValue(termRow, SKOS.EXAMPLE).ifPresent( + ex -> populatePluralMultilingualString(term::getExamples, term::setExamples, + splitIntoMultipleValues(ex))); getAttributeValue(termRow, DC.Terms.SOURCE).ifPresent(src -> term.setSources(splitIntoMultipleValues(src))); getAttributeValue(termRow, SKOS.BROADER).ifPresent(br -> setParentTerms(term, splitIntoMultipleValues(br))); getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); + getAttributeValue(termRow, SKOS.RELATED).ifPresent(rt -> mapSkosRelationship(term, splitIntoMultipleValues(rt), SKOS.RELATED)); } private MultilingualString initSingularMultilingualString(Supplier getter, @@ -129,6 +172,23 @@ private void setParentTerms(Term term, Set parentLabels) { }); } + private void mapSkosRelationship(Term subject, Set objects, String property) { + final URI propertyUri = URI.create(property); + objects.forEach(object -> { + final Term objectTerm = labelToTerm.get(object); + if (objectTerm == null) { + LOG.warn("No term with label '{}' found for term '{}' and relationship <{}>.", object, subject.getLabel().get(langTag), property); + } else { + // Term IDs are not generated, yet + rawDataToInsert.add(new ExcelImporter.TermRelationship(subject, propertyUri, objectTerm)); + } + }); + } + + List getRawDataToInsert() { + return rawDataToInsert; + } + private static Optional resolveLanguage(Sheet sheet) { final List codes = LanguageCode.findByName(sheet.getSheetName()); if (codes.isEmpty()) { diff --git a/src/main/resources/attributes/cs.properties b/src/main/resources/attributes/cs.properties index a69ff48d1..c3f1740c8 100644 --- a/src/main/resources/attributes/cs.properties +++ b/src/main/resources/attributes/cs.properties @@ -7,4 +7,5 @@ http\://www.w3.org/2004/02/skos/core#example=P\u0159 http\://www.w3.org/2004/02/skos/core#notation=Notace http\://purl.org/dc/terms/source=Zdroj http\://www.w3.org/2004/02/skos/core#broader=Nad\u0159azené pojmy +http\://www.w3.org/2004/02/skos/core#related=Související pojmy http\://purl.org/dc/terms/references=Reference diff --git a/src/main/resources/attributes/en.properties b/src/main/resources/attributes/en.properties index 45c227e88..6a2f3bebf 100644 --- a/src/main/resources/attributes/en.properties +++ b/src/main/resources/attributes/en.properties @@ -7,6 +7,7 @@ http\://www.w3.org/2004/02/skos/core#example=Example http\://www.w3.org/2004/02/skos/core#notation=Notation http\://purl.org/dc/terms/source=Source http\://www.w3.org/2004/02/skos/core#broader=Parent terms +http\://www.w3.org/2004/02/skos/core#related=Related terms http\://purl.org/dc/terms/references=References diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/DataDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/DataDaoTest.java index 55e3325bb..29097b9a5 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/DataDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/DataDaoTest.java @@ -27,6 +27,7 @@ import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.User; +import cz.cvut.kbss.termit.persistence.dao.util.Quad; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.Vocabulary; @@ -52,7 +53,11 @@ import java.util.List; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class DataDaoTest extends BaseDaoTestRunner { @@ -170,7 +175,7 @@ void getLabelReturnsEmptyOptionalForIdentifierWithMultipleLabels() { final Repository repo = em.unwrap(Repository.class); final ValueFactory vf = repo.getValueFactory(); try (final RepositoryConnection connection = repo.getConnection()) { - connection.add(vf.createIRI(term.getUri().toString()), RDF.TYPE,SKOS.CONCEPT); + connection.add(vf.createIRI(term.getUri().toString()), RDF.TYPE, SKOS.CONCEPT); connection.add(vf.createIRI(term.getUri().toString()), SKOS.PREF_LABEL, vf.createLiteral(term.getPrimaryLabel())); connection.add(vf.createIRI(term.getUri().toString()), SKOS.PREF_LABEL, @@ -200,7 +205,7 @@ void persistSavesSpecifiedResource() { @Test void exportDataToTurtleReturnsResourceRepresentingTurtleData() { generateProperties(); - transactional(() -> { + readOnlyTransactional(() -> { final TypeAwareResource result = sut.exportDataAsTurtle(); assertNotNull(result); assertTrue(result.getMediaType().isPresent()); @@ -214,7 +219,7 @@ void exportDataToTurtleReturnsResourceRepresentingTurtleData() { void exportDataToTurtleExportsDefaultContextWhenNoArgumentsAreProvided() { generateProperties(); final ValueFactory vf = SimpleValueFactory.getInstance(); - transactional(() -> { + readOnlyTransactional(() -> { final TypeAwareResource result = sut.exportDataAsTurtle(); final Model model = parseExportToModel(result); assertAll(() -> assertTrue(model.contains(vf.createIRI(Vocabulary.s_p_ma_krestni_jmeno), RDFS.LABEL, @@ -239,7 +244,7 @@ void exportDataToTurtleExportsContextWhenProvided() { connection.commit(); } }); - transactional(() -> { + readOnlyTransactional(() -> { final TypeAwareResource result = sut.exportDataAsTurtle(context); Model model = parseExportToModel(result); assertTrue(model.contains(SKOS.CONCEPT, RDFS.LABEL, vf.createLiteral("Term"))); @@ -281,4 +286,26 @@ private void generateLabelTranslations() { } }); } + + @Test + void insertDataInsertsSpecifiedQuadsIntoRepository() { + final URI context = Generator.generateUri(); + final URI termOne = Generator.generateUri(); + final URI termTwo = Generator.generateUri(); + final List quads = List.of( + new Quad(termOne, URI.create(RDF.TYPE.stringValue()), URI.create(SKOS.CONCEPT.stringValue()), context), + new Quad(termTwo, URI.create(RDF.TYPE.stringValue()), URI.create(SKOS.CONCEPT.stringValue()), context), + new Quad(termOne, URI.create(SKOS.RELATED.stringValue()), termTwo, context) + ); + + transactional(() -> sut.insertRawData(quads)); + readOnlyTransactional(() -> assertTrue( + em.createNativeQuery("ASK WHERE { GRAPH ?ctx { ?x a ?type . ?y a ?type . ?x ?related ?y . } }", + Boolean.class) + .setParameter("x", termOne) + .setParameter("y", termTwo) + .setParameter("ctx", context) + .setParameter("related", URI.create(SKOS.RELATED.stringValue())) + .setParameter("type", URI.create(SKOS.CONCEPT.stringValue())).getSingleResult())); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 2ffd6dfb3..0fa5e643a 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -2,12 +2,16 @@ import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jopa.vocabulary.DC; +import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.exception.importing.VocabularyDoesNotExistException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.persistence.dao.DataDao; import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.persistence.dao.util.Quad; +import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.util.Constants; @@ -21,6 +25,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.net.URI; +import java.util.Collection; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; @@ -29,7 +36,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -46,6 +55,9 @@ class ExcelImporterTest { @Mock private Consumer prePersist; + @Mock + private DataDao dataDao; + @InjectMocks private ExcelImporter sut; @@ -80,43 +92,67 @@ void importThrowsVocabularyDoesNotExistExceptionWhenNoVocabularyIdentifierIsProv void importThrowsVocabularyDoesNotExistExceptionWhenVocabularyIsNotFound() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(false); assertThrows(VocabularyDoesNotExistException.class, - () -> sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), - new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, - Environment.loadFile( - "data/import-simple-en.xlsx")))); + () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx")))); } @Test void importCreatesRootTermsWithBasicAttributesFromEnglishSheet() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(false); - final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), - new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, - Environment.loadFile( - "data/import-simple-en.xlsx"))); + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx"))); assertEquals(vocabulary, result); final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); assertEquals(2, captor.getAllValues().size()); - final Optional building = captor.getAllValues().stream().filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + final Optional building = captor.getAllValues().stream() + .filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); assertTrue(building.isPresent()); assertEquals("Definition of term Building", building.get().getDefinition().get("en")); assertEquals("Building scope note", building.get().getDescription().get("en")); - final Optional construction = captor.getAllValues().stream().filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + final Optional construction = captor.getAllValues().stream() + .filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); assertTrue(construction.isPresent()); assertEquals("The process of building a building", construction.get().getDefinition().get("en")); } + private void initIdentifierGenerator(boolean forChild) { + doAnswer(inv -> { + final Term t = inv.getArgument(0); + t.setUri(URI.create(vocabulary.getUri().toString() + "/" + IdentifierResolver.normalize( + t.getLabel().get(Constants.DEFAULT_LANGUAGE)))); + return null; + }).when(termService).addRootTermToVocabulary(any(Term.class), eq(vocabulary)); + if (forChild) { + doAnswer(inv -> { + final Term t = inv.getArgument(0); + t.setUri(URI.create(vocabulary.getUri().toString() + "/" + IdentifierResolver.normalize( + t.getLabel().get(Constants.DEFAULT_LANGUAGE)))); + return null; + }).when(termService).addChildTerm(any(Term.class), any(Term.class)); + } + } + @Test void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(false); - final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), - new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, - Environment.loadFile( - "data/import-with-plural-atts-en.xlsx"))); + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-plural-atts-en.xlsx"))); assertEquals(vocabulary, result); final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); verify(termService).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); @@ -145,23 +181,27 @@ void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { void importCreatesRootTermsWithBasicAttributesFromMultipleTranslationSheets() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(false); - final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), - new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, - Environment.loadFile( - "data/import-simple-en-cs.xlsx"))); + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en-cs.xlsx"))); assertEquals(vocabulary, result); final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); assertEquals(2, captor.getAllValues().size()); - final Optional building = captor.getAllValues().stream().filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + final Optional building = captor.getAllValues().stream() + .filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); assertTrue(building.isPresent()); assertEquals("Budova", building.get().getLabel().get("cs")); assertEquals("Definition of term Building", building.get().getDefinition().get("en")); assertEquals("Definice pojmu budova", building.get().getDefinition().get("cs")); assertEquals("Building scope note", building.get().getDescription().get("en")); assertEquals("Doplňující poznámka pojmu budova", building.get().getDescription().get("cs")); - final Optional construction = captor.getAllValues().stream().filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + final Optional construction = captor.getAllValues().stream() + .filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); assertTrue(construction.isPresent()); assertEquals("Stavba", construction.get().getLabel().get("cs")); assertEquals("The process of building a building", construction.get().getDefinition().get("en")); @@ -172,25 +212,35 @@ void importCreatesRootTermsWithBasicAttributesFromMultipleTranslationSheets() { void importCreatesRootTermsWithPluralBasicAttributesFromMultipleTranslationSheets() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(false); - final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), - new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, - Environment.loadFile( - "data/import-with-plural-atts-en-cs.xlsx"))); + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-plural-atts-en-cs.xlsx"))); assertEquals(vocabulary, result); final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); verify(termService).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); assertEquals(1, captor.getAllValues().size()); final Term building = captor.getValue(); assertEquals("Budova", building.getLabel().get("cs")); - assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("Structure"))); - assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("House"))); - assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("dům"))); - assertTrue(building.getAltLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("stavba"))); - assertTrue(building.getHiddenLabels().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("bldng"))); - assertTrue(building.getHiddenLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("barák"))); - assertTrue(building.getExamples().stream().anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("Dancing house"))); - assertTrue(building.getExamples().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("TanÄící dům"))); + assertTrue(building.getAltLabels().stream() + .anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("Structure"))); + assertTrue(building.getAltLabels().stream() + .anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("House"))); + assertTrue( + building.getAltLabels().stream().anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("dům"))); + assertTrue(building.getAltLabels().stream() + .anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("stavba"))); + assertTrue(building.getHiddenLabels().stream() + .anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("bldng"))); + assertTrue(building.getHiddenLabels().stream() + .anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("barák"))); + assertTrue(building.getExamples().stream() + .anyMatch(mls -> mls.get("en") != null && mls.get("en").equals("Dancing house"))); + assertTrue(building.getExamples().stream() + .anyMatch(mls -> mls.get("cs") != null && mls.get("cs").equals("TanÄící dům"))); assertEquals(Set.of("B"), building.getNotations()); assertEquals(Set.of("a56"), building.getProperties().get(DC.Terms.REFERENCES)); } @@ -199,11 +249,13 @@ void importCreatesRootTermsWithPluralBasicAttributesFromMultipleTranslationSheet void importCreatesTermHierarchy() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(true); - final Vocabulary result = sut.importVocabulary(new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), - new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, - Environment.loadFile( - "data/import-hierarchy-en.xlsx"))); + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-hierarchy-en.xlsx"))); assertEquals(vocabulary, result); final ArgumentCaptor rootCaptor = ArgumentCaptor.forClass(Term.class); verify(termService).addRootTermToVocabulary(rootCaptor.capture(), eq(vocabulary)); @@ -216,4 +268,25 @@ void importCreatesTermHierarchy() { final Term buildableArea = childCaptor.getValue(); assertEquals("Buildable area", buildableArea.getLabel().get("en")); } + + @Test + void importSavesRelationshipsBetweenTerms() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(false); + + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-references-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor termCaptor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(termCaptor.capture(), eq(vocabulary)); + final ArgumentCaptor> quadsCaptor = ArgumentCaptor.forClass(Collection.class); + verify(dataDao).insertRawData(quadsCaptor.capture()); + assertEquals(1, quadsCaptor.getValue().size()); + assertEquals(List.of(new Quad(termCaptor.getAllValues().get(1).getUri(), URI.create(SKOS.RELATED), + termCaptor.getAllValues().get(0).getUri(), vocabulary.getUri())), quadsCaptor.getValue()); + } } From 4be6c03609fa2e5bdc9f062e38dfaa524f7c5e62 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 25 Jul 2024 17:56:22 +0200 Subject: [PATCH 022/150] [kbss-cvut/termit-ui#449] Fix Excel importing issues after testing with frontend. --- .../service/importer/excel/ExcelImporter.java | 5 +++- .../excel/LocalizedSheetImporter.java | 4 ++- .../VocabularyRepositoryService.java | 2 +- .../importer/excel/ExcelImporterTest.java | 26 ++++++++++++++++++ .../resources/data/import-en-empty-cs.xlsx | Bin 0 -> 65458 bytes .../data/import-with-references-en.xlsx | Bin 0 -> 35559 bytes 6 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/test/resources/data/import-en-empty-cs.xlsx create mode 100644 src/test/resources/data/import-with-references-en.xlsx diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index 2eb8d2bf0..372907536 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -72,7 +72,10 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) } // Ensure all parents are saved before we start adding children terms.stream().filter(t -> Utils.emptyIfNull(t.getParentTerms()).isEmpty()) - .forEach(root -> termService.addRootTermToVocabulary(root, targetVocabulary)); + .forEach(root -> { + termService.addRootTermToVocabulary(root, targetVocabulary); + root.setVocabulary(targetVocabulary.getUri()); + }); terms.stream().filter(t -> !Utils.emptyIfNull(t.getParentTerms()).isEmpty()) .forEach(t -> termService.addChildTerm(t, t.getParentTerms().iterator().next())); // Insert term relationships as raw data because of possible object conflicts in the persistence context - diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index a88f27f01..7cd1472c9 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -110,7 +110,9 @@ private void findTerms(Sheet sheet) { initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label.get()); labelToTerm.put(label.get(), term); } - assert labelToTerm.size() == i - 1; + for (; i <= existingTerms.size(); i++) { + labelToTerm.put(existingTerms.get(i - 1).getLabel().get(), existingTerms.get(i - 1)); + } } private void mapRowToTermAttributes(Term term, Row termRow) { diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index 17d1d713b..02b4adf71 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -251,7 +251,7 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { } catch (VocabularyImportException e) { throw e; } catch (Exception e) { - throw new VocabularyImportException("Unable to import vocabulary, because of: " + e.getMessage()); + throw new VocabularyImportException("Unable to import vocabulary, because of: " + e.getMessage(), e); } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 0fa5e643a..bbcbabc0b 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -289,4 +289,30 @@ void importSavesRelationshipsBetweenTerms() { assertEquals(List.of(new Quad(termCaptor.getAllValues().get(1).getUri(), URI.create(SKOS.RELATED), termCaptor.getAllValues().get(0).getUri(), vocabulary.getUri())), quadsCaptor.getValue()); } + + @Test + void importImportsTermsWhenAnotherLanguageSheetIsEmpty() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(false); + + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-en-empty-cs.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(2, captor.getAllValues().size()); + final Optional building = captor.getAllValues().stream() + .filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + assertTrue(building.isPresent()); + assertEquals("Definition of term Building", building.get().getDefinition().get("en")); + assertEquals("Building scope note", building.get().getDescription().get("en")); + final Optional construction = captor.getAllValues().stream() + .filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + assertTrue(construction.isPresent()); + assertEquals("The process of building a building", construction.get().getDefinition().get("en")); + } } diff --git a/src/test/resources/data/import-en-empty-cs.xlsx b/src/test/resources/data/import-en-empty-cs.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..22b2f382124e88c7e93b9ddf9b3036d84ff65c86 GIT binary patch literal 65458 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 literal 0 HcmV?d00001 diff --git a/src/test/resources/data/import-with-references-en.xlsx b/src/test/resources/data/import-with-references-en.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..04c1733420791d65a6bc67e20dff8d0877f48e13 GIT binary patch literal 35559 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 literal 0 HcmV?d00001 From 7f186d6b64db38d424575013efe749efdbeb3b97 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 25 Jul 2024 18:23:16 +0200 Subject: [PATCH 023/150] [kbss-cvut/termit-ui#449] Handle null cells in imported Excel. Null cells occur when there are no more columns after them. --- .../excel/LocalizedSheetImporter.java | 13 +++++++++++-- .../resources/template/termit-import.xlsx | Bin 65237 -> 65556 bytes 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 7cd1472c9..bb576b08d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -72,6 +72,8 @@ List resolveTermsFromSheet(Sheet sheet) { LOG.trace("Sheet '{}' mapped to language tag '{}'.", sheet.getSheetName(), langTag); final Properties attributeMapping = new Properties(); this.labelToTerm = new LinkedHashMap<>(); + // TODO How to handle languages for which we do not have column names (attribute mapping)? + // Use English as backup try { attributeMapping.load( getClass().getClassLoader().getResourceAsStream("attributes/" + langTag + ".properties")); @@ -134,7 +136,8 @@ private void mapRowToTermAttributes(Term term, Row termRow) { getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); - getAttributeValue(termRow, SKOS.RELATED).ifPresent(rt -> mapSkosRelationship(term, splitIntoMultipleValues(rt), SKOS.RELATED)); + getAttributeValue(termRow, SKOS.RELATED).ifPresent( + rt -> mapSkosRelationship(term, splitIntoMultipleValues(rt), SKOS.RELATED)); } private MultilingualString initSingularMultilingualString(Supplier getter, @@ -179,7 +182,8 @@ private void mapSkosRelationship(Term subject, Set objects, String prope objects.forEach(object -> { final Term objectTerm = labelToTerm.get(object); if (objectTerm == null) { - LOG.warn("No term with label '{}' found for term '{}' and relationship <{}>.", object, subject.getLabel().get(langTag), property); + LOG.warn("No term with label '{}' found for term '{}' and relationship <{}>.", object, + subject.getLabel().get(langTag), property); } else { // Term IDs are not generated, yet rawDataToInsert.add(new ExcelImporter.TermRelationship(subject, propertyUri, objectTerm)); @@ -217,6 +221,11 @@ private static Map resolveAttributeColumns(Row attributes, Prop } private Optional getAttributeValue(Row row, String attributeIri) { + final Cell cell = row.getCell(attributeToColumn.get(attributeIri)); + if (cell == null) { + // The cell may be null instead of blank if there are no other columns behind at + return Optional.empty(); + } final String cellValue = row.getCell(attributeToColumn.get(attributeIri)).getStringCellValue(); return cellValue.isBlank() ? Optional.empty() : Optional.of(cellValue.trim()); } diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx index 2a71b6e94df6d1bc10ec87ea5eb0695004b361ab..feb0d1f13f3257dcec9a954098cef29a5e25c618 100644 GIT binary patch literal 65556 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 literal 65237 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 Date: Mon, 29 Jul 2024 10:15:19 +0200 Subject: [PATCH 024/150] [kbss-cvut/termit-ui#449] Handle languages other than Czech and English when importing Excel sheet. --- .../excel/LocalizedSheetImporter.java | 18 +++++-- .../importer/excel/ExcelImporterTest.java | 45 ++++++++++++++---- src/test/resources/data/import-simple-de.xlsx | Bin 0 -> 35538 bytes 3 files changed, 49 insertions(+), 14 deletions(-) create mode 100644 src/test/resources/data/import-simple-de.xlsx diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index bb576b08d..7d988fc67 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.util.ArrayList; import java.util.Collections; @@ -38,6 +39,8 @@ class LocalizedSheetImporter { private static final Logger LOG = LoggerFactory.getLogger(LocalizedSheetImporter.class); + private static final String FALLBACK_LANGUAGE = "en"; + private final List existingTerms; private Map attributeToColumn; @@ -72,11 +75,8 @@ List resolveTermsFromSheet(Sheet sheet) { LOG.trace("Sheet '{}' mapped to language tag '{}'.", sheet.getSheetName(), langTag); final Properties attributeMapping = new Properties(); this.labelToTerm = new LinkedHashMap<>(); - // TODO How to handle languages for which we do not have column names (attribute mapping)? - // Use English as backup try { - attributeMapping.load( - getClass().getClassLoader().getResourceAsStream("attributes/" + langTag + ".properties")); + attributeMapping.load(resolveColumnMappingFile()); final Row attributes = sheet.getRow(0); this.attributeToColumn = resolveAttributeColumns(attributes, attributeMapping); } catch (IOException e) { @@ -91,6 +91,16 @@ List resolveTermsFromSheet(Sheet sheet) { return new ArrayList<>(labelToTerm.values()); } + private InputStream resolveColumnMappingFile() { + if (getClass().getClassLoader().getResource("attributes/" + langTag + ".properties") != null) { + LOG.trace("Loading attribute mapping for language tag '{}'.", langTag); + return getClass().getClassLoader().getResourceAsStream("attributes/" + langTag + ".properties"); + } else { + LOG.trace("No attribute mapping found for language tag '{}', falling back to '{}'.", langTag, FALLBACK_LANGUAGE); + return getClass().getClassLoader().getResourceAsStream("attributes/" + FALLBACK_LANGUAGE + ".properties"); + } + } + /** * First map terms to labels. *

diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index bbcbabc0b..242d8a355 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -103,7 +103,7 @@ void importThrowsVocabularyDoesNotExistExceptionWhenVocabularyIsNotFound() { void importCreatesRootTermsWithBasicAttributesFromEnglishSheet() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(false); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -125,18 +125,18 @@ void importCreatesRootTermsWithBasicAttributesFromEnglishSheet() { assertEquals("The process of building a building", construction.get().getDefinition().get("en")); } - private void initIdentifierGenerator(boolean forChild) { + private void initIdentifierGenerator(String lang, boolean forChild) { doAnswer(inv -> { final Term t = inv.getArgument(0); t.setUri(URI.create(vocabulary.getUri().toString() + "/" + IdentifierResolver.normalize( - t.getLabel().get(Constants.DEFAULT_LANGUAGE)))); + t.getLabel().get(lang)))); return null; }).when(termService).addRootTermToVocabulary(any(Term.class), eq(vocabulary)); if (forChild) { doAnswer(inv -> { final Term t = inv.getArgument(0); t.setUri(URI.create(vocabulary.getUri().toString() + "/" + IdentifierResolver.normalize( - t.getLabel().get(Constants.DEFAULT_LANGUAGE)))); + t.getLabel().get(lang)))); return null; }).when(termService).addChildTerm(any(Term.class), any(Term.class)); } @@ -146,7 +146,7 @@ private void initIdentifierGenerator(boolean forChild) { void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(false); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -181,7 +181,7 @@ void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { void importCreatesRootTermsWithBasicAttributesFromMultipleTranslationSheets() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(false); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -212,7 +212,7 @@ void importCreatesRootTermsWithBasicAttributesFromMultipleTranslationSheets() { void importCreatesRootTermsWithPluralBasicAttributesFromMultipleTranslationSheets() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(false); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -249,7 +249,7 @@ void importCreatesRootTermsWithPluralBasicAttributesFromMultipleTranslationSheet void importCreatesTermHierarchy() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(true); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, true); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -273,7 +273,7 @@ void importCreatesTermHierarchy() { void importSavesRelationshipsBetweenTerms() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(false); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -294,7 +294,7 @@ void importSavesRelationshipsBetweenTerms() { void importImportsTermsWhenAnotherLanguageSheetIsEmpty() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(false); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE,false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -315,4 +315,29 @@ void importImportsTermsWhenAnotherLanguageSheetIsEmpty() { assertTrue(construction.isPresent()); assertEquals("The process of building a building", construction.get().getDefinition().get("en")); } + + @Test + void importFallsBackToEnglishColumnLabelsForUnknownLanguages() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator("de", false); + + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-de.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(2, captor.getAllValues().size()); + final Optional building = captor.getAllValues().stream() + .filter(t -> "Gebäude".equals(t.getLabel().get("de"))).findAny(); + assertTrue(building.isPresent()); + assertEquals("Definition für ein Gebäude", building.get().getDefinition().get("de")); + final Optional construction = captor.getAllValues().stream() + .filter(t -> "Bau".equals(t.getLabel().get("de"))).findAny(); + assertTrue(construction.isPresent()); + assertEquals("Ein Prozess", construction.get().getDefinition().get("de")); + } } diff --git a/src/test/resources/data/import-simple-de.xlsx b/src/test/resources/data/import-simple-de.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ad228ac7453179a6f5a186c487e6eba730b14ee7 GIT binary patch literal 35538 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 ZnmN12S`0XQGGe7f-h((A51ah%{{gF4AjJRx literal 0 HcmV?d00001 From 44ceee6763711a5dc74c263de9ec6ba46e3f106b Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 29 Jul 2024 13:00:32 +0200 Subject: [PATCH 025/150] [kbss-cvut/termit-ui#449] Support sheets with identifier column and using prefixed identifiers as well as full ones. --- .../export/ExcelVocabularyExporter.java | 35 ++++++++-- .../export/util/TabularTermExportUtils.java | 1 + .../service/importer/excel/ExcelImporter.java | 22 +++++- .../excel/LocalizedSheetImporter.java | 42 ++++++++--- .../service/importer/excel/PrefixMap.java | 47 +++++++++++++ src/main/resources/attributes/cs.properties | 1 + src/main/resources/attributes/en.properties | 1 + .../importer/excel/ExcelImporterTest.java | 66 ++++++++++++++++++ .../data/import-with-identifiers-en.xlsx | Bin 0 -> 35671 bytes .../import-with-prefixed-identifiers-en.xlsx | Bin 0 -> 36853 bytes 10 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/service/importer/excel/PrefixMap.java create mode 100644 src/test/resources/data/import-with-identifiers-en.xlsx create mode 100644 src/test/resources/data/import-with-prefixed-identifiers-en.xlsx diff --git a/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java b/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java index fb4bef3bf..1006abfb8 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java @@ -24,7 +24,6 @@ import cz.cvut.kbss.termit.exception.UnsupportedOperationException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; @@ -43,7 +42,15 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; /** @@ -51,6 +58,20 @@ */ @Service("excel") public class ExcelVocabularyExporter implements VocabularyExporter { + + /** + * Name of the sheet where prefix mapping should be put + */ + public static final String PREFIX_SHEET_NAME = "Prefix"; + + /** + * Index number of the prefix column + */ + public static final int PREFIX_COLUMN_NUMBER = 0; + /** + * Index number of the namespace column + */ + public static final int NAMESPACE_COLUMN_NUMBER = 1; /** * Name of the prefix column in the prefix mapping sheet */ @@ -189,7 +210,7 @@ private void resolvePrefixes(Term t, Map prefixes) { } private void generatePrefixMappingSheet(XSSFWorkbook wb, Collection prefixes) { - final XSSFSheet sheet = wb.createSheet(PREFIX_COLUMN); + final XSSFSheet sheet = wb.createSheet(PREFIX_SHEET_NAME); generatePrefixSheetHeader(sheet); final XSSFFont font = initFont(wb); final CellStyle style = wb.createCellStyle(); @@ -205,16 +226,16 @@ private void generatePrefixMappingSheet(XSSFWorkbook wb, Collection 0; + PrefixMap prefixMap = resolvePrefixMap(workbook); for (int i = 0; i < workbook.getNumberOfSheets(); i++) { final Sheet sheet = workbook.getSheetAt(i); - final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter(terms); + if (ExcelVocabularyExporter.PREFIX_SHEET_NAME.equals(sheet.getSheetName())) { + // Skip already processed prefix sheet + continue; + } + final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter(prefixMap, terms); terms = sheetImporter.resolveTermsFromSheet(sheet); rawDataToInsert.addAll(sheetImporter.getRawDataToInsert()); } @@ -90,6 +100,16 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) return targetVocabulary; } + private PrefixMap resolvePrefixMap(Workbook excel) { + final Sheet prefixSheet = excel.getSheet(ExcelVocabularyExporter.PREFIX_SHEET_NAME); + if (prefixSheet == null) { + return new PrefixMap(); + } else { + LOG.debug("Loading prefix map from sheet '{}'.", ExcelVocabularyExporter.PREFIX_SHEET_NAME); + return new PrefixMap(prefixSheet); + } + } + /** * Checks whether this importer supports the specified media type. * diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 7d988fc67..4315594f0 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -4,6 +4,7 @@ import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.jopa.vocabulary.SKOS; +import cz.cvut.kbss.jsonld.JsonLd; import cz.cvut.kbss.termit.model.Term; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; @@ -41,15 +42,18 @@ class LocalizedSheetImporter { private static final String FALLBACK_LANGUAGE = "en"; + private final PrefixMap prefixMap; private final List existingTerms; private Map attributeToColumn; private String langTag; private Map labelToTerm; + private Map idToTerm; private List rawDataToInsert; - LocalizedSheetImporter(List existingTerms) { + LocalizedSheetImporter(PrefixMap prefixMap, List existingTerms) { + this.prefixMap = prefixMap; this.existingTerms = existingTerms; } @@ -75,6 +79,7 @@ List resolveTermsFromSheet(Sheet sheet) { LOG.trace("Sheet '{}' mapped to language tag '{}'.", sheet.getSheetName(), langTag); final Properties attributeMapping = new Properties(); this.labelToTerm = new LinkedHashMap<>(); + this.idToTerm = new HashMap<>(); try { attributeMapping.load(resolveColumnMappingFile()); final Row attributes = sheet.getRow(0); @@ -96,7 +101,8 @@ private InputStream resolveColumnMappingFile() { LOG.trace("Loading attribute mapping for language tag '{}'.", langTag); return getClass().getClassLoader().getResourceAsStream("attributes/" + langTag + ".properties"); } else { - LOG.trace("No attribute mapping found for language tag '{}', falling back to '{}'.", langTag, FALLBACK_LANGUAGE); + LOG.trace("No attribute mapping found for language tag '{}', falling back to '{}'.", langTag, + FALLBACK_LANGUAGE); return getClass().getClassLoader().getResourceAsStream("attributes/" + FALLBACK_LANGUAGE + ".properties"); } } @@ -121,6 +127,10 @@ private void findTerms(Sheet sheet) { } initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label.get()); labelToTerm.put(label.get(), term); + getAttributeValue(termRow, JsonLd.ID).ifPresent(id -> { + term.setUri(URI.create(prefixMap.resolvePrefixed(id))); + idToTerm.put(term.getUri(), term); + }); } for (; i <= existingTerms.size(); i++) { labelToTerm.put(existingTerms.get(i - 1).getLabel().get(), existingTerms.get(i - 1)); @@ -190,17 +200,27 @@ private void setParentTerms(Term term, Set parentLabels) { private void mapSkosRelationship(Term subject, Set objects, String property) { final URI propertyUri = URI.create(property); objects.forEach(object -> { - final Term objectTerm = labelToTerm.get(object); - if (objectTerm == null) { - LOG.warn("No term with label '{}' found for term '{}' and relationship <{}>.", object, - subject.getLabel().get(langTag), property); - } else { - // Term IDs are not generated, yet - rawDataToInsert.add(new ExcelImporter.TermRelationship(subject, propertyUri, objectTerm)); + try { + final Term objectTerm = getTerm(object); + if (objectTerm == null) { + LOG.warn("No term with label '{}' found for term '{}' and relationship <{}>.", object, + subject.getLabel().get(langTag), property); + } else { + // Term IDs may not be generated, yet + rawDataToInsert.add(new ExcelImporter.TermRelationship(subject, propertyUri, objectTerm)); + } + } catch (IllegalArgumentException e) { + LOG.warn("Could not create URI for value '{}' and it does not reference another term by label either", + object); } }); } + private Term getTerm(String identification) { + return labelToTerm.containsKey(identification) ? labelToTerm.get(identification) : + idToTerm.get(URI.create(prefixMap.resolvePrefixed(identification))); + } + List getRawDataToInsert() { return rawDataToInsert; } @@ -231,6 +251,10 @@ private static Map resolveAttributeColumns(Row attributes, Prop } private Optional getAttributeValue(Row row, String attributeIri) { + if (!attributeToColumn.containsKey(attributeIri)) { + // Attribute column is not present at all + return Optional.empty(); + } final Cell cell = row.getCell(attributeToColumn.get(attributeIri)); if (cell == null) { // The cell may be null instead of blank if there are no other columns behind at diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/PrefixMap.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/PrefixMap.java new file mode 100644 index 000000000..4aa209359 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/PrefixMap.java @@ -0,0 +1,47 @@ +package cz.cvut.kbss.termit.service.importer.excel; + +import cz.cvut.kbss.termit.service.export.ExcelVocabularyExporter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; + +import java.util.HashMap; +import java.util.Map; + +class PrefixMap { + + private final Map prefixMap = new HashMap<>(); + + PrefixMap() { + } + + PrefixMap(Sheet prefixSheet) { + for (Row row : prefixSheet) { + if (row.getRowNum() == 0) { + continue; + } + + if (row.getCell(ExcelVocabularyExporter.PREFIX_COLUMN_NUMBER) == null || row.getCell( + ExcelVocabularyExporter.PREFIX_COLUMN_NUMBER).getStringCellValue().isBlank()) { + return; + } + final String prefix = row.getCell(ExcelVocabularyExporter.PREFIX_COLUMN_NUMBER).getStringCellValue(); + final String uri = row.getCell(ExcelVocabularyExporter.NAMESPACE_COLUMN_NUMBER).getStringCellValue(); + prefixMap.put(prefix, uri); + } + } + + String resolvePrefixed(String value) { + final int colonIndex = value.indexOf(':'); + if (colonIndex > 0) { + final String prefix = value.substring(0, colonIndex); + return prefixMap.containsKey(prefix) ? (prefixMap.get(prefix) + value.substring(colonIndex + 1)) : value; + } else { + return value; + } + } + + @Override + public String toString() { + return "PrefixMap{" + prefixMap + '}'; + } +} diff --git a/src/main/resources/attributes/cs.properties b/src/main/resources/attributes/cs.properties index c3f1740c8..491de5ac9 100644 --- a/src/main/resources/attributes/cs.properties +++ b/src/main/resources/attributes/cs.properties @@ -9,3 +9,4 @@ http\://purl.org/dc/terms/source=Zdroj http\://www.w3.org/2004/02/skos/core#broader=Nad\u0159azené pojmy http\://www.w3.org/2004/02/skos/core#related=Související pojmy http\://purl.org/dc/terms/references=Reference +@id=Identifikátor diff --git a/src/main/resources/attributes/en.properties b/src/main/resources/attributes/en.properties index 6a2f3bebf..1ae95193a 100644 --- a/src/main/resources/attributes/en.properties +++ b/src/main/resources/attributes/en.properties @@ -9,5 +9,6 @@ http\://purl.org/dc/terms/source=Source http\://www.w3.org/2004/02/skos/core#broader=Parent terms http\://www.w3.org/2004/02/skos/core#related=Related terms http\://purl.org/dc/terms/references=References +@id=Identifier diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 242d8a355..eaadaac6a 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -128,6 +128,9 @@ void importCreatesRootTermsWithBasicAttributesFromEnglishSheet() { private void initIdentifierGenerator(String lang, boolean forChild) { doAnswer(inv -> { final Term t = inv.getArgument(0); + if (t.getUri() != null) { + return null; + } t.setUri(URI.create(vocabulary.getUri().toString() + "/" + IdentifierResolver.normalize( t.getLabel().get(lang)))); return null; @@ -135,6 +138,9 @@ private void initIdentifierGenerator(String lang, boolean forChild) { if (forChild) { doAnswer(inv -> { final Term t = inv.getArgument(0); + if (t.getUri() != null) { + return null; + } t.setUri(URI.create(vocabulary.getUri().toString() + "/" + IdentifierResolver.normalize( t.getLabel().get(lang)))); return null; @@ -340,4 +346,64 @@ void importFallsBackToEnglishColumnLabelsForUnknownLanguages() { assertTrue(construction.isPresent()); assertEquals("Ein Prozess", construction.get().getDefinition().get("de")); } + + @Test + void importSupportsTermIdentifiers() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE,false); + + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-identifiers-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(2, captor.getAllValues().size()); + final Optional building = captor.getAllValues().stream() + .filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + assertTrue(building.isPresent()); + assertEquals(URI.create("http://example.com/terms/building"), building.get().getUri()); + final Optional construction = captor.getAllValues().stream() + .filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + assertTrue(construction.isPresent()); + assertEquals(URI.create("http://example.com/terms/construction"), construction.get().getUri()); + final ArgumentCaptor> quadsCaptor = ArgumentCaptor.forClass(Collection.class); + verify(dataDao).insertRawData(quadsCaptor.capture()); + assertEquals(1, quadsCaptor.getValue().size()); + assertEquals(List.of(new Quad(construction.get().getUri(), URI.create(SKOS.RELATED), + building.get().getUri(), vocabulary.getUri())), quadsCaptor.getValue()); + } + + @Test + void importSupportsPrefixedTermIdentifiers() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE,false); + + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-prefixed-identifiers-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(2, captor.getAllValues().size()); + final Optional building = captor.getAllValues().stream() + .filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + assertTrue(building.isPresent()); + assertEquals(URI.create("http://example.com/terms/building"), building.get().getUri()); + final Optional construction = captor.getAllValues().stream() + .filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + assertTrue(construction.isPresent()); + assertEquals(URI.create("http://example.com/terms/construction"), construction.get().getUri()); + final ArgumentCaptor> quadsCaptor = ArgumentCaptor.forClass(Collection.class); + verify(dataDao).insertRawData(quadsCaptor.capture()); + assertEquals(1, quadsCaptor.getValue().size()); + assertEquals(List.of(new Quad(construction.get().getUri(), URI.create(SKOS.RELATED), + building.get().getUri(), vocabulary.getUri())), quadsCaptor.getValue()); + } } diff --git a/src/test/resources/data/import-with-identifiers-en.xlsx b/src/test/resources/data/import-with-identifiers-en.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..dd6f72995257bc3a14aa4cdaf533115505bf6d31 GIT binary patch literal 35671 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 literal 0 HcmV?d00001 From e82ff372a6dd6ea1834da162a7baf5a44d2c1faa Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 29 Jul 2024 15:36:19 +0200 Subject: [PATCH 026/150] [kbss-cvut/termit-ui#449] Use semicolon when splitting multiple values in imported Excel cell. Semicolon is used in export as well. --- .../service/importer/excel/ExcelImporter.java | 5 ++++- .../excel/LocalizedSheetImporter.java | 15 +++++++++------ .../resources/template/termit-import.xlsx | Bin 65556 -> 65560 bytes 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index 3be86b00d..bd0fc1d85 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -87,7 +87,10 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) root.setVocabulary(targetVocabulary.getUri()); }); terms.stream().filter(t -> !Utils.emptyIfNull(t.getParentTerms()).isEmpty()) - .forEach(t -> termService.addChildTerm(t, t.getParentTerms().iterator().next())); + .forEach(t -> { + t.setVocabulary(targetVocabulary.getUri()); + termService.addChildTerm(t, t.getParentTerms().iterator().next()); + }); // Insert term relationships as raw data because of possible object conflicts in the persistence context - // the same term being as multiple types (Term, TermInfo) in the same persistence context dataDao.insertRawData(rawDataToInsert.stream().map(tr -> new Quad(tr.subject().getUri(), tr.property(), diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 4315594f0..981866da7 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -6,6 +6,7 @@ import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.jsonld.JsonLd; import cz.cvut.kbss.termit.model.Term; +import cz.cvut.kbss.termit.service.export.util.TabularTermExportUtils; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -155,7 +156,7 @@ private void mapRowToTermAttributes(Term term, Row termRow) { getAttributeValue(termRow, SKOS.BROADER).ifPresent(br -> setParentTerms(term, splitIntoMultipleValues(br))); getAttributeValue(termRow, SKOS.NOTATION).ifPresent(nt -> term.setNotations(splitIntoMultipleValues(nt))); getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( - nt -> term.setProperties(Map.of(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); + nt -> term.setProperties(Collections.singletonMap(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); getAttributeValue(termRow, SKOS.RELATED).ifPresent( rt -> mapSkosRelationship(term, splitIntoMultipleValues(rt), SKOS.RELATED)); } @@ -186,11 +187,12 @@ private void populatePluralMultilingualString( } } - private void setParentTerms(Term term, Set parentLabels) { - parentLabels.forEach(label -> { - final Term parent = labelToTerm.get(label); + private void setParentTerms(Term term, Set parents) { + parents.forEach(parentIdentification -> { + final Term parent = getTerm(parentIdentification); if (parent == null) { - LOG.warn("No parent term with label '{}' for term '{}'.", label, term.getLabel().get(langTag)); + LOG.warn("No parent term with label or identifier '{}' found for term '{}'.", parentIdentification, + term.getLabel().get(langTag)); } else { term.addParentTerm(parent); } @@ -265,6 +267,7 @@ private Optional getAttributeValue(Row row, String attributeIri) { } private static Set splitIntoMultipleValues(String value) { - return Stream.of(value.split(",")).map(String::trim).collect(Collectors.toSet()); + return Stream.of(value.split(TabularTermExportUtils.STRING_DELIMITER)).map(String::trim) + .collect(Collectors.toSet()); } } diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx index feb0d1f13f3257dcec9a954098cef29a5e25c618..fe734b69f143f797b0b280a77eb7b34b8e3801fe 100644 GIT binary patch literal 65560 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 bwP$H1;4ZW80-CL((^K^y=tQwn_1*sfQk1*3 literal 65556 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 From cccba4e1f5b00cd9f961821b585813c4f0cb7ea9 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 29 Jul 2024 15:42:35 +0200 Subject: [PATCH 027/150] [kbss-cvut/termit-ui#449] Fix broken test (change of delimiter from , to ;) --- .../data/import-with-plural-atts-en-cs.xlsx | Bin 65549 -> 65552 bytes .../data/import-with-plural-atts-en.xlsx | Bin 35618 -> 35612 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/src/test/resources/data/import-with-plural-atts-en-cs.xlsx b/src/test/resources/data/import-with-plural-atts-en-cs.xlsx index 33032c2efd75529422c3ef7c777d6f9fa49dc526..cfd06ab739f59824fab4c37747b8eed05d1e56c1 100644 GIT binary patch literal 65552 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 literal 65549 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 diff --git a/src/test/resources/data/import-with-plural-atts-en.xlsx b/src/test/resources/data/import-with-plural-atts-en.xlsx index 30acc97ad75808e96e86ba50c20a949f434c0aff..2fa8e28eb07151fa8c5b822c85801de3ef00e5ce 100644 GIT binary patch delta 9332 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 delta 8342 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 G|M`C_qbeZ) From 60335cb3c1e44c850c8b473c72892074fea9d9a3 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 5 Aug 2024 16:33:56 +0200 Subject: [PATCH 028/150] [kbss-cvut/termit-ui#449] Reuse imported term identifier when available. But adjust it to match expected vocabulary namespace if necessary. --- .../service/importer/excel/ExcelImporter.java | 29 +++++++++++- .../excel/LocalizedSheetImporter.java | 37 ++++++++++++++- .../importer/excel/ExcelImporterTest.java | 46 +++++++++++++++++-- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index bd0fc1d85..9fe0c2b17 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -8,9 +8,11 @@ import cz.cvut.kbss.termit.persistence.dao.DataDao; import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; import cz.cvut.kbss.termit.persistence.dao.util.Quad; +import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.export.ExcelVocabularyExporter; import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; +import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Utils; import org.apache.poi.ss.usermodel.Sheet; @@ -48,10 +50,16 @@ public class ExcelImporter implements VocabularyImporter { private final TermRepositoryService termService; private final DataDao dataDao; - public ExcelImporter(VocabularyDao vocabularyDao, TermRepositoryService termService, DataDao dataDao) { + private final IdentifierResolver idResolver; + private final Configuration config; + + public ExcelImporter(VocabularyDao vocabularyDao, TermRepositoryService termService, DataDao dataDao, + IdentifierResolver idResolver, Configuration config) { this.vocabularyDao = vocabularyDao; this.termService = termService; this.dataDao = dataDao; + this.idResolver = idResolver; + this.config = config; } @Override @@ -63,6 +71,7 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) } final Vocabulary targetVocabulary = vocabularyDao.find(config.vocabularyIri()).orElseThrow( () -> NotFoundException.create(Vocabulary.class, config.vocabularyIri())); + final String termNamespace = resolveVocabularyTermNamespace(targetVocabulary); try { List terms = Collections.emptyList(); Set rawDataToInsert = new HashSet<>(); @@ -76,19 +85,22 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) // Skip already processed prefix sheet continue; } - final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter(prefixMap, terms); + final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter(prefixMap, terms, + idResolver, termNamespace); terms = sheetImporter.resolveTermsFromSheet(sheet); rawDataToInsert.addAll(sheetImporter.getRawDataToInsert()); } // Ensure all parents are saved before we start adding children terms.stream().filter(t -> Utils.emptyIfNull(t.getParentTerms()).isEmpty()) .forEach(root -> { + LOG.trace("Persisting root term {}.", root); termService.addRootTermToVocabulary(root, targetVocabulary); root.setVocabulary(targetVocabulary.getUri()); }); terms.stream().filter(t -> !Utils.emptyIfNull(t.getParentTerms()).isEmpty()) .forEach(t -> { t.setVocabulary(targetVocabulary.getUri()); + LOG.trace("Persisting child term {}.", t); termService.addChildTerm(t, t.getParentTerms().iterator().next()); }); // Insert term relationships as raw data because of possible object conflicts in the persistence context - @@ -113,6 +125,19 @@ private PrefixMap resolvePrefixMap(Workbook excel) { } } + /** + * Resolves namespace for identifiers of terms in the specified vocabulary. + *

+ * It uses the vocabulary identifier and the configured term namespace separator. + * + * @param vocabulary Vocabulary whose term identifier namespace to resolve + * @return Resolved namespace + */ + private String resolveVocabularyTermNamespace(Vocabulary vocabulary) { + return idResolver.buildNamespace(vocabulary.getUri().toString(), + config.getNamespace().getTerm().getSeparator()); + } + /** * Checks whether this importer supports the specified media type. * diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 981866da7..4016f5767 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -6,7 +6,9 @@ import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.jsonld.JsonLd; import cz.cvut.kbss.termit.model.Term; +import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.export.util.TabularTermExportUtils; +import cz.cvut.kbss.termit.util.Utils; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -45,6 +47,9 @@ class LocalizedSheetImporter { private final PrefixMap prefixMap; private final List existingTerms; + private final IdentifierResolver idResolver; + // Namespace expected based on the vocabulary into which the terms will be imported + private final String expectedTermNamespace; private Map attributeToColumn; private String langTag; @@ -53,9 +58,12 @@ class LocalizedSheetImporter { private Map idToTerm; private List rawDataToInsert; - LocalizedSheetImporter(PrefixMap prefixMap, List existingTerms) { + LocalizedSheetImporter(PrefixMap prefixMap, List existingTerms, IdentifierResolver idResolver, + String expectedTermNamespace) { this.prefixMap = prefixMap; this.existingTerms = existingTerms; + this.idResolver = idResolver; + this.expectedTermNamespace = expectedTermNamespace; } /** @@ -129,7 +137,7 @@ private void findTerms(Sheet sheet) { initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label.get()); labelToTerm.put(label.get(), term); getAttributeValue(termRow, JsonLd.ID).ifPresent(id -> { - term.setUri(URI.create(prefixMap.resolvePrefixed(id))); + term.setUri(resolveTermUri(id)); idToTerm.put(term.getUri(), term); }); } @@ -138,6 +146,31 @@ private void findTerms(Sheet sheet) { } } + /** + * If an identifier column is found in the sheet, attempt to use it for term ids. + *

+ * This methods first resolves possible prefixes and then ensures the identifier matches the expected namespace + * provided by the vocabulary into which we are importing. If it does not match, a new identifier is generated based + * on the expected namespace and the local name extracted from the identifier present in the sheet. + * + * @param id Identifier extracted from the sheet + * @return Valid term identifier matching target vocabulary + */ + private URI resolveTermUri(String id) { + // Handle prefix if it is used + id = prefixMap.resolvePrefixed(id); + final URI uriId = URI.create(id); + if (expectedTermNamespace.equals(IdentifierResolver.extractIdentifierNamespace(uriId))) { + return URI.create(id); + } else { + LOG.trace( + "Existing term identifier {} does not correspond to the expected vocabulary term namespace {}. Adjusting the term id.", + Utils.uriToString(uriId), expectedTermNamespace); + final String localName = IdentifierResolver.extractIdentifierFragment(uriId); + return idResolver.generateIdentifier(expectedTermNamespace, localName); + } + } + private void mapRowToTermAttributes(Term term, Row termRow) { getAttributeValue(termRow, SKOS.DEFINITION).ifPresent( d -> initSingularMultilingualString(term::getDefinition, term::setDefinition).set(langTag, d)); diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index eaadaac6a..bfc780afb 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -14,6 +14,7 @@ import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.importer.VocabularyImporter; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; +import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,11 +24,13 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import java.net.URI; import java.util.Collection; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; @@ -58,6 +61,13 @@ class ExcelImporterTest { @Mock private DataDao dataDao; + @Spy + private Configuration config = new Configuration(); + + @SuppressWarnings("unused") + @Spy + private IdentifierResolver idResolver = new IdentifierResolver(); + @InjectMocks private ExcelImporter sut; @@ -66,6 +76,7 @@ class ExcelImporterTest { @BeforeEach void setUp() { this.vocabulary = Generator.generateVocabularyWithId(); + config.getNamespace().getTerm().setSeparator("/terms"); } @ParameterizedTest @@ -293,14 +304,15 @@ void importSavesRelationshipsBetweenTerms() { verify(dataDao).insertRawData(quadsCaptor.capture()); assertEquals(1, quadsCaptor.getValue().size()); assertEquals(List.of(new Quad(termCaptor.getAllValues().get(1).getUri(), URI.create(SKOS.RELATED), - termCaptor.getAllValues().get(0).getUri(), vocabulary.getUri())), quadsCaptor.getValue()); + termCaptor.getAllValues().get(0).getUri(), vocabulary.getUri())), + quadsCaptor.getValue()); } @Test void importImportsTermsWhenAnotherLanguageSheetIsEmpty() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE,false); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -349,9 +361,10 @@ void importFallsBackToEnglishColumnLabelsForUnknownLanguages() { @Test void importSupportsTermIdentifiers() { + vocabulary.setUri(URI.create("http://example.com")); when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE,false); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -367,7 +380,7 @@ void importSupportsTermIdentifiers() { assertTrue(building.isPresent()); assertEquals(URI.create("http://example.com/terms/building"), building.get().getUri()); final Optional construction = captor.getAllValues().stream() - .filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + .filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); assertTrue(construction.isPresent()); assertEquals(URI.create("http://example.com/terms/construction"), construction.get().getUri()); final ArgumentCaptor> quadsCaptor = ArgumentCaptor.forClass(Collection.class); @@ -379,9 +392,10 @@ void importSupportsTermIdentifiers() { @Test void importSupportsPrefixedTermIdentifiers() { + vocabulary.setUri(URI.create("http://example.com")); when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE,false); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -406,4 +420,26 @@ void importSupportsPrefixedTermIdentifiers() { assertEquals(List.of(new Quad(construction.get().getUri(), URI.create(SKOS.RELATED), building.get().getUri(), vocabulary.getUri())), quadsCaptor.getValue()); } + + @Test + void importAdjustsTermIdentifiersToUseExistingVocabularyIdentifierAndSeparatorAsNamespace() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); + + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-identifiers-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertTrue(captor.getAllValues().stream().anyMatch(t -> Objects.equals(URI.create( + vocabulary.getUri().toString() + config.getNamespace().getTerm().getSeparator() + "/building"), + t.getUri()))); + assertTrue(captor.getAllValues().stream().anyMatch(t -> Objects.equals(URI.create( + vocabulary.getUri().toString() + config.getNamespace().getTerm().getSeparator() + "/construction"), + t.getUri()))); + } } From e1f0905668114424f749c2d22c5ee1c98650bd33 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 5 Aug 2024 17:58:08 +0200 Subject: [PATCH 029/150] [kbss-cvut/termit-ui#449] Ensure all available rows in a sheet are processed. --- .../excel/LocalizedSheetImporter.java | 32 ++++++++++--------- src/main/resources/logback-spring.xml | 6 ++++ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 4016f5767..463495c4e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -54,8 +54,8 @@ class LocalizedSheetImporter { private Map attributeToColumn; private String langTag; - private Map labelToTerm; - private Map idToTerm; + private final Map labelToTerm = new LinkedHashMap<>(); + private final Map idToTerm = new HashMap<>(); private List rawDataToInsert; LocalizedSheetImporter(PrefixMap prefixMap, List existingTerms, IdentifierResolver idResolver, @@ -64,6 +64,7 @@ class LocalizedSheetImporter { this.existingTerms = existingTerms; this.idResolver = idResolver; this.expectedTermNamespace = expectedTermNamespace; + existingTerms.stream().filter(t -> t.getUri() != null).forEach(t -> idToTerm.put(t.getUri(), t)); } /** @@ -87,8 +88,6 @@ List resolveTermsFromSheet(Sheet sheet) { this.langTag = lang.get().name(); LOG.trace("Sheet '{}' mapped to language tag '{}'.", sheet.getSheetName(), langTag); final Properties attributeMapping = new Properties(); - this.labelToTerm = new LinkedHashMap<>(); - this.idToTerm = new HashMap<>(); try { attributeMapping.load(resolveColumnMappingFile()); final Row attributes = sheet.getRow(0); @@ -126,24 +125,27 @@ private InputStream resolveColumnMappingFile() { */ private void findTerms(Sheet sheet) { int i; - for (i = 1; i < sheet.getLastRowNum(); i++) { + for (i = 1; i <= sheet.getLastRowNum(); i++) { final Row termRow = sheet.getRow(i); Term term = existingTerms.size() >= i ? existingTerms.get(i - 1) : new Term(); - final Optional label = getAttributeValue(termRow, SKOS.PREF_LABEL); - if (label.isEmpty()) { - LOG.trace("Reached empty label column cell at row {}. Working with {} terms.", i, (i - 1)); - break; - } - initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label.get()); - labelToTerm.put(label.get(), term); getAttributeValue(termRow, JsonLd.ID).ifPresent(id -> { term.setUri(resolveTermUri(id)); idToTerm.put(term.getUri(), term); }); + final Optional label = getAttributeValue(termRow, SKOS.PREF_LABEL); + if (label.isPresent()) { + initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label.get()); + labelToTerm.put(label.get(), term); + } else { + if (i > existingTerms.size()) { + LOG.trace("Reached empty label column cell at row {}.", i); + break; + } else { + labelToTerm.put(existingTerms.get(i - 1).getLabel().get(), existingTerms.get(i - 1)); + } + } } - for (; i <= existingTerms.size(); i++) { - labelToTerm.put(existingTerms.get(i - 1).getLabel().get(), existingTerms.get(i - 1)); - } + LOG.trace("Found {} term rows.", i - 1); } /** diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 3c6065f51..f62771a49 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -29,6 +29,12 @@ + + + + + + From 3aaccd6232cade0befaceab8e401afe93e572ffe Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Fri, 2 Aug 2024 13:15:46 +0200 Subject: [PATCH 030/150] Removal of non-empty vocabularies --- .../cvut/kbss/termit/dto/RdfsStatement.java | 74 ++++++ .../termit/event/VocabularyRemovalEvent.java | 23 ++ .../termit/persistence/dao/VocabularyDao.java | 147 ++++++++++-- .../persistence/dao/skos/SKOSImporter.java | 2 +- .../termit/rest/VocabularyController.java | 48 +++- .../service/business/ResourceService.java | 14 ++ .../service/business/VocabularyService.java | 20 +- .../VocabularyRepositoryService.java | 60 ++++- .../cz/cvut/kbss/termit/util/Constants.java | 7 + .../kbss/termit/environment/Generator.java | 16 ++ .../termit/persistence/dao/TermDaoTest.java | 5 +- .../persistence/dao/VocabularyDaoTest.java | 221 +++++++++++++++++- .../termit/rest/VocabularyControllerTest.java | 23 +- .../VocabularyRepositoryServiceTest.java | 56 ++++- 14 files changed, 673 insertions(+), 43 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/dto/RdfsStatement.java create mode 100644 src/main/java/cz/cvut/kbss/termit/event/VocabularyRemovalEvent.java diff --git a/src/main/java/cz/cvut/kbss/termit/dto/RdfsStatement.java b/src/main/java/cz/cvut/kbss/termit/dto/RdfsStatement.java new file mode 100644 index 000000000..2a8114b77 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/dto/RdfsStatement.java @@ -0,0 +1,74 @@ +package cz.cvut.kbss.termit.dto; + +import cz.cvut.kbss.jopa.model.annotations.ConstructorResult; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; +import cz.cvut.kbss.jopa.model.annotations.SparqlResultSetMapping; +import cz.cvut.kbss.jopa.model.annotations.VariableResult; +import cz.cvut.kbss.jopa.model.annotations.util.NonEntity; +import cz.cvut.kbss.jopa.vocabulary.RDF; + +import java.io.Serializable; +import java.net.URI; + +/** + * Utility class describing a generic {@link #relation} between an {@link #object} and {@link #subject} + */ +@NonEntity +@OWLClass(iri = RDF.STATEMENT) +@SparqlResultSetMapping(name = "RDFStatement", + classes = {@ConstructorResult(targetClass = RdfsStatement.class, + variables = { + @VariableResult(name = "object", type = URI.class), + @VariableResult(name = "relation", type = URI.class), + @VariableResult(name = "subject", type = URI.class), + })}) +public class RdfsStatement implements Serializable { + + @ParticipationConstraints(nonEmpty = true) + @OWLObjectProperty(iri = RDF.OBJECT) + private URI object; + + @ParticipationConstraints(nonEmpty = true) + @OWLAnnotationProperty(iri = RDF.PREDICATE) + private URI relation; + + @ParticipationConstraints(nonEmpty = true) + @OWLObjectProperty(iri = RDF.SUBJECT) + private URI subject; + + public RdfsStatement() { + } + + public RdfsStatement(URI object, URI relation, URI subject) { + this.object = object; + this.relation = relation; + this.subject = subject; + } + + public URI getObject() { + return object; + } + + public void setObject(URI object) { + this.object = object; + } + + public URI getRelation() { + return relation; + } + + public void setRelation(URI relation) { + this.relation = relation; + } + + public URI getSubject() { + return subject; + } + + public void setSubject(URI subject) { + this.subject = subject; + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyRemovalEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyRemovalEvent.java new file mode 100644 index 000000000..a5470691c --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyRemovalEvent.java @@ -0,0 +1,23 @@ +package cz.cvut.kbss.termit.event; + +import cz.cvut.kbss.termit.model.Vocabulary; +import org.springframework.context.ApplicationEvent; + +import java.net.URI; + +/** + * Indicates that a Vocabulary will be removed + *

+ * Fired in {@link cz.cvut.kbss.termit.persistence.dao.VocabularyDao#remove(Vocabulary)} right before the vocabulary removal. + */ +public class VocabularyRemovalEvent extends ApplicationEvent { + private final URI vocabulary; + public VocabularyRemovalEvent(Object source, URI vocabulary) { + super(source); + this.vocabulary = vocabulary; + } + + public URI getVocabulary() { + return vocabulary; + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index 535293e90..7413e619d 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -25,14 +25,18 @@ import cz.cvut.kbss.termit.asset.provenance.SupportsLastModification; import cz.cvut.kbss.termit.dto.AggregatedChangeInfo; import cz.cvut.kbss.termit.dto.PrefixDeclaration; +import cz.cvut.kbss.termit.dto.RdfsStatement; import cz.cvut.kbss.termit.dto.Snapshot; import cz.cvut.kbss.termit.event.AssetPersistEvent; import cz.cvut.kbss.termit.event.AssetUpdateEvent; import cz.cvut.kbss.termit.event.RefreshLastModifiedEvent; +import cz.cvut.kbss.termit.event.VocabularyRemovalEvent; import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.model.Glossary; +import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.resource.Document; +import cz.cvut.kbss.termit.model.util.EntityToOwlClassMapper; import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.persistence.context.DescriptorFactory; import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper; @@ -51,7 +55,17 @@ import java.net.URI; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import static cz.cvut.kbss.termit.util.Constants.DEFAULT_PAGE_SIZE; +import static cz.cvut.kbss.termit.util.Constants.SKOS_CONCEPT_MATCH_RELATIONSHIPS; @Repository public class VocabularyDao extends BaseAssetDao @@ -197,41 +211,60 @@ public Vocabulary update(Vocabulary entity) { } } + /** + * Forcefully removes the specified vocabulary. + *

+ * This deletes the whole graph of the vocabulary, all terms in the vocabulary's glossary and then removes the vocabulary itself. Extreme caution + * should be exercised when using this method. All relevant data, including documents and files, will be dropped. + *

+ * Publishes {@link VocabularyRemovalEvent} before the actual removal to allow other services to clean up related resources (e.g., delete the document). + * @param entity The vocabulary to delete + */ @ModifiesData @Override public void remove(Vocabulary entity) { - Objects.requireNonNull(entity); - try { - find(entity.getUri()).ifPresent(elem -> { - em.remove(elem); - refreshLastModified(); - }); - } catch (RuntimeException e) { - throw new PersistenceException(e); - } + eventPublisher.publishEvent(new VocabularyRemovalEvent(this, entity.getUri())); + this.removeVocabulary(entity, true); } /** + *

+ * Does not publishes the {@link VocabularyRemovalEvent}.
+ * You should use {@link #remove(Vocabulary)} instead. + *

* Forcefully removes the specified vocabulary. *

* This deletes all terms in the vocabulary's glossary and then removes the vocabulary itself. Extreme caution * should be exercised when using this method, as it does not check for any references or usage and just drops all * the relevant data. - * * @param entity The vocabulary to delete + * @param dropGraph if false, + * executes {@code src/main/resources/query/remove/removeGlossaryTerms.ru} removing terms, + * their relations, model, glossary and vocabulary itself, keeps the document. + * When true, the whole vocabulary graph is dropped. */ - @ModifiesData - public void forceRemove(Vocabulary entity) { + public void removeVocabulary(Vocabulary entity, boolean dropGraph) { Objects.requireNonNull(entity); LOG.debug("Forcefully removing vocabulary {} and all its contents.", entity); try { - final URI context = contextMapper.getVocabularyContext(entity); - em.createNativeQuery(Utils.loadQuery(REMOVE_GLOSSARY_TERMS_QUERY_FILE)) - .setParameter("g", context) - .setParameter("vocabulary", entity) - .executeUpdate(); - remove(entity); - em.getEntityManagerFactory().getCache().evict(context); + final URI vocabularyContext = contextMapper.getVocabularyContext(entity.getUri()); + + if(dropGraph) { + // drops whole named graph + em.createNativeQuery("DROP GRAPH ?context") + .setParameter("context", vocabularyContext) + .executeUpdate(); + } else { + // removes all terms and their relations from named graph + em.createNativeQuery(Utils.loadQuery(REMOVE_GLOSSARY_TERMS_QUERY_FILE)) + .setParameter("g", vocabularyContext) + .setParameter("vocabulary", entity.getUri()) + .executeUpdate(); + } + + find(entity.getUri()).ifPresent(em::remove); + refreshLastModified(); + em.getEntityManagerFactory().getCache().evict(vocabularyContext); } catch (RuntimeException e) { throw new PersistenceException(e); } @@ -376,7 +409,7 @@ public boolean isEmpty(Vocabulary vocabulary) { "?inVocabulary ?vocabulary ." + " }", Boolean.class) .setParameter("type", URI.create(SKOS.CONCEPT)) - .setParameter("vocabulary", vocabulary) + .setParameter("vocabulary", vocabulary.getUri()) .setParameter("inVocabulary", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_p_je_pojmem_ze_slovniku)) .getSingleResult(); @@ -479,4 +512,76 @@ public PrefixDeclaration resolvePrefix(URI vocabularyUri) { throw new PersistenceException(e); } } + + /** + * @return all relations between specified vocabulary and all other vocabularies + */ + public List getVocabularyRelations(Vocabulary vocabulary, Collection excludedRelations) { + Objects.requireNonNull(vocabulary); + final URI vocabularyUri = vocabulary.getUri(); + + try { + return em.createNativeQuery(""" + SELECT DISTINCT ?object ?relation ?subject { + ?object a ?vocabularyType ; + ?relation ?subject . + FILTER(?object != ?subject) . + FILTER(?relation NOT IN (?excluded)) . + } ORDER BY ?object ?relation + """, "RDFStatement") + .setParameter("subject", vocabularyUri) + .setParameter("excluded", excludedRelations) + .setParameter("vocabularyType", URI.create(EntityToOwlClassMapper.getOwlClassForEntity(Vocabulary.class))) + .getResultList(); + } catch (RuntimeException e) { + throw new PersistenceException(e); + } + } + + /** + * @return all relations between terms in specified vocabulary and all terms from any other vocabulary + */ + public List getTermRelations(Vocabulary vocabulary) { + Objects.requireNonNull(vocabulary); + final URI vocabularyUri = vocabulary.getUri(); + final URI termType = URI.create(EntityToOwlClassMapper.getOwlClassForEntity(Term.class)); + final URI inVocabulary = URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_p_je_pojmem_ze_slovniku); + + try { + return em.createNativeQuery(""" + SELECT DISTINCT ?object ?relation ?subject WHERE { + ?term a ?termType; + ?inVocabulary ?vocabulary . + + { + ?term ?relation ?secondTerm . + ?secondTerm a ?termType; + ?inVocabulary ?secondVocabulary . + + BIND(?term as ?object) + BIND(?secondTerm as ?subject) + } UNION { + ?secondTerm ?relation ?term . + ?secondTerm a ?termType; + ?inVocabulary ?secondVocabulary . + + BIND(?secondTerm as ?object) + BIND(?term as ?subject) + } + + FILTER(?relation IN (?deniedRelations)) + FILTER(?object != ?subject) + FILTER(?secondVocabulary != ?vocabulary) + } ORDER by ?object ?relation ?subject + """, "RDFStatement" + ).setMaxResults(DEFAULT_PAGE_SIZE) + .setParameter("termType", termType) + .setParameter("inVocabulary", inVocabulary) + .setParameter("vocabulary", vocabularyUri) + .setParameter("deniedRelations", SKOS_CONCEPT_MATCH_RELATIONSHIPS) + .getResultList(); + } catch (RuntimeException e) { + throw new PersistenceException(e); + } + } } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java index 663822ee7..04150b78c 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java @@ -206,7 +206,7 @@ private void clearVocabulary(Vocabulary newVocabulary) { possibleVocabulary.ifPresent(toRemove -> { newVocabulary.setDocument(toRemove.getDocument()); newVocabulary.setAcl(toRemove.getAcl()); - vocabularyDao.forceRemove(toRemove); + vocabularyDao.removeVocabulary(toRemove, false); }); } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 31590c906..a5cf80674 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -19,6 +19,7 @@ import cz.cvut.kbss.jsonld.JsonLd; import cz.cvut.kbss.termit.dto.AggregatedChangeInfo; +import cz.cvut.kbss.termit.dto.RdfsStatement; import cz.cvut.kbss.termit.dto.Snapshot; import cz.cvut.kbss.termit.dto.acl.AccessControlListDto; import cz.cvut.kbss.termit.dto.listing.VocabularyDto; @@ -351,10 +352,49 @@ public void removeVocabulary(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCR @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace) { final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); - vocabularyService.find(identifier).ifPresent(toRemove -> { - vocabularyService.remove(toRemove); - LOG.debug("Vocabulary {} removed.", toRemove); - }); + final Vocabulary vocabulary = vocabularyService.findRequired(identifier); + vocabularyService.remove(vocabulary); + LOG.debug("Vocabulary {} removed.", vocabulary); + } + + @Operation(security = {@SecurityRequirement(name = "bearer-key")}, + description = "Returns relations with other vocabularies") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "A collection of vocabulary relations"), + @ApiResponse(responseCode = "404", description = ApiDoc.ID_NOT_FOUND_DESCRIPTION), + }) + @GetMapping(value = "/{localName}/relations") + public List relations(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, + example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) + @PathVariable String localName, + @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, + example = ApiDoc.ID_NAMESPACE_EXAMPLE) + @RequestParam(name = QueryParams.NAMESPACE, + required = false) Optional namespace) { + final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); + final Vocabulary vocabulary = vocabularyService.findRequired(identifier); + + return vocabularyService.getVocabularyRelations(vocabulary); + } + + @Operation(security = {@SecurityRequirement(name = "bearer-key")}, + description = "Returns relations with terms from other vocabularies") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "A collection of term relations"), + @ApiResponse(responseCode = "404", description = ApiDoc.ID_NOT_FOUND_DESCRIPTION), + }) + @GetMapping(value = "/{localName}/terms/relations") + public List termsRelations(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, + example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) + @PathVariable String localName, + @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, + example = ApiDoc.ID_NAMESPACE_EXAMPLE) + @RequestParam(name = QueryParams.NAMESPACE, + required = false) Optional namespace) { + final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); + final Vocabulary vocabulary = vocabularyService.findRequired(identifier); + + return vocabularyService.getTermRelations(vocabulary); } @Operation(description = "Validates the terms in a vocabulary with the specified identifier.") diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java index 084004a3c..8e5ddef58 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java @@ -20,6 +20,7 @@ import cz.cvut.kbss.termit.asset.provenance.SupportsLastModification; import cz.cvut.kbss.termit.event.DocumentRenameEvent; import cz.cvut.kbss.termit.event.FileRenameEvent; +import cz.cvut.kbss.termit.event.VocabularyRemovalEvent; import cz.cvut.kbss.termit.exception.InvalidParameterException; import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.exception.UnsupportedAssetOperationException; @@ -44,6 +45,7 @@ import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.context.event.EventListener; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -91,6 +93,18 @@ public ResourceService(ResourceRepositoryService repositoryService, DocumentMana this.changeRecordService = changeRecordService; } + /** + * Ensures that document gets removed during Vocabulary removal + */ + @EventListener + public void onVocabularyRemoval(VocabularyRemovalEvent event) { + vocabularyService.find(event.getVocabulary()).ifPresent(vocabulary -> { + if(vocabulary.getDocument() != null) { + remove(vocabulary.getDocument()); + } + }); + } + /** * Removes resource with the specified identifier. *

diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index ff5ec6430..7e631ceeb 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -19,6 +19,7 @@ import cz.cvut.kbss.termit.asset.provenance.SupportsLastModification; import cz.cvut.kbss.termit.dto.AggregatedChangeInfo; +import cz.cvut.kbss.termit.dto.RdfsStatement; import cz.cvut.kbss.termit.dto.Snapshot; import cz.cvut.kbss.termit.dto.acl.AccessControlListDto; import cz.cvut.kbss.termit.dto.listing.TermDto; @@ -57,6 +58,8 @@ import java.time.Instant; import java.util.*; +import static cz.cvut.kbss.termit.util.Constants.VOCABULARY_REMOVAL_IGNORED_RELATIONS; + /** * Business logic concerning vocabularies. *

@@ -115,6 +118,9 @@ public long getLastModified() { return repositoryService.getLastModified(); } + /** + * @return {@link cz.cvut.kbss.termit.dto.VocabularyDto} + */ @Override @PostAuthorize("@vocabularyAuthorizationService.canRead(returnObject)") public Optional find(URI id) { @@ -182,6 +188,16 @@ public Set getRelatedVocabularies(Vocabulary entity) { return repositoryService.getRelatedVocabularies(entity); } + @PreAuthorize("@vocabularyAuthorizationService.canRead(#entity)") + public List getTermRelations(Vocabulary vocabulary) { + return repositoryService.getTermRelations(vocabulary); + } + + @PreAuthorize("@vocabularyAuthorizationService.canRead(#entity)") + public List getVocabularyRelations(Vocabulary vocabulary) { + return repositoryService.getVocabularyRelations(vocabulary, VOCABULARY_REMOVAL_IGNORED_RELATIONS); + } + /** * Imports a new vocabulary from the specified file. *

@@ -274,7 +290,7 @@ public void runTextAnalysisOnAllVocabularies() { *

* * @param asset Vocabulary to remove @@ -283,8 +299,8 @@ public void runTextAnalysisOnAllVocabularies() { @PreAuthorize("@vocabularyAuthorizationService.canRemove(#asset)") public void remove(Vocabulary asset) { Vocabulary toRemove = repositoryService.findRequired(asset.getUri()); - aclService.findFor(toRemove).ifPresent(aclService::remove); repositoryService.remove(toRemove); + aclService.findFor(toRemove).ifPresent(aclService::remove); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index 1c23c5b4e..dd006d9ed 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -19,6 +19,7 @@ import cz.cvut.kbss.termit.dto.AggregatedChangeInfo; import cz.cvut.kbss.termit.dto.PrefixDeclaration; +import cz.cvut.kbss.termit.dto.RdfsStatement; import cz.cvut.kbss.termit.dto.Snapshot; import cz.cvut.kbss.termit.dto.listing.VocabularyDto; import cz.cvut.kbss.termit.dto.mapper.DtoMapper; @@ -263,22 +264,63 @@ public long getLastModified() { return vocabularyDao.getLastModified(); } + /** + * Removes a vocabulary unless: + *
    + *
  • it is imported by another vocabulary, other relation with another vocabulary exists or
  • + *
  • it contains terms that are a part of relations with another vocabulary
  • + *
+ */ @PreAuthorize("@vocabularyAuthorizationService.canRemove(#instance)") @CacheEvict(allEntries = true) @Transactional @Override public void remove(Vocabulary instance) { - final List vocabularies = vocabularyDao.getImportingVocabularies(instance); + super.remove(instance); + } + + /** + * Ensures that the vocabulary to be removed complies with the rules allowing removal. + *
    + *
  • it is imported by another vocabulary or
  • + *
  • it contains terms that are a part of relations with another vocabulary
  • + *
+ * + * @param instance The instance to be removed, not {@code null} + */ + @Override + protected void preRemove(Vocabulary instance) { + ensureNotImported(instance); + ensureNoTermRelationsExists(instance); + super.preRemove(instance); + } + + /** + * Ensures there is no other vocabulary importing the {@code vocabulary} + * + * @param vocabulary The Vocabulary to search if its imported + * @throws AssetRemovalException when there is a vocabulary importing the {@code vocabulary} + */ + private void ensureNotImported(Vocabulary vocabulary) throws AssetRemovalException { + final List vocabularies = vocabularyDao.getImportingVocabularies(vocabulary); if (!vocabularies.isEmpty()) { throw new AssetRemovalException( "Vocabulary cannot be removed. It is referenced from other vocabularies: " + vocabularies.stream().map(Vocabulary::getPrimaryLabel).collect(Collectors.joining(", "))); } - if (!vocabularyDao.isEmpty(instance)) { - throw new AssetRemovalException("Vocabulary cannot be removed. It contains terms."); - } + } - super.remove(instance); + /** + * Ensures there are no terms in other vocabularies with a relation to any term in this {@code vocabulary} + * + * @param vocabulary The vocabulary + * @throws AssetRemovalException when there is a vocabulary with a term and relation to a term in the {@code vocabulary} + */ + private void ensureNoTermRelationsExists(Vocabulary vocabulary) throws AssetRemovalException { + final List relations = vocabularyDao.getTermRelations(vocabulary); + if (!relations.isEmpty()) { + throw new AssetRemovalException("Vocabulary cannot be removed. There are relations with other vocabularies."); + } } public List validateContents(Vocabulary instance) { @@ -289,6 +331,14 @@ public Integer getTermCount(Vocabulary vocabulary) { return vocabularyDao.getTermCount(vocabulary); } + public List getTermRelations(Vocabulary vocabulary) { + return vocabularyDao.getTermRelations(vocabulary); + } + + public List getVocabularyRelations(Vocabulary vocabulary, Collection excludedRelations) { + return vocabularyDao.getVocabularyRelations(vocabulary, excludedRelations); + } + @Transactional(readOnly = true) public List findSnapshots(Vocabulary vocabulary) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index c33e0969c..f625855d1 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -131,6 +131,13 @@ public class Constants { SKOS.BROAD_MATCH, SKOS.NARROW_MATCH, SKOS.EXACT_MATCH, SKOS.RELATED_MATCH ).map(URI::create).collect(Collectors.toSet()); + /** + * Relations between vocabularies that do not prevent vocabulary to be removed + */ + public static final Set VOCABULARY_REMOVAL_IGNORED_RELATIONS = Stream.of( + Vocabulary.s_p_je_verzi, Vocabulary.s_p_is_snapshot_of, Vocabulary.s_p_je_verzi_slovniku + ).map(URI::create).collect(Collectors.toSet()); + /** * Labels of columns representing exported term attributes in various supported languages. */ diff --git a/src/test/java/cz/cvut/kbss/termit/environment/Generator.java b/src/test/java/cz/cvut/kbss/termit/environment/Generator.java index baaecda1b..47ea16cf2 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/Generator.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/Generator.java @@ -527,4 +527,20 @@ public static List> generateAccessControlRecords() { result.add(rr); return result; } + + /** + * Adds relation into entity manager connection + * @implNote call in transactional + */ + public static void addRelation(URI subject, URI predicate, URI object, EntityManager em) { + final Repository repo = em.unwrap(Repository.class); + try (RepositoryConnection conn = repo.getConnection()) { + final ValueFactory vf = conn.getValueFactory(); + conn.begin(); + conn.add(vf.createIRI(subject.toString()), + vf.createIRI(predicate.toString()), + vf.createIRI(object.toString())); + conn.commit(); + } + } } diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java index 6f24abf49..b908ec514 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java @@ -781,9 +781,10 @@ void findAllRootsReturnsTermsInMultipleLanguagesWithoutPrimaryLabelInCorrectOrde "Duitsland", "Sjina", "Kina", "Tyskland", "China", "Germany", - "Chiny", "Niemcy"); + "Chiny", "Niemcy" + ); - assertEquals(10, result.size()); + assertEquals(expectedOrder.size(), result.size()); assertEquals(expectedOrder, labels); } diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java index 423fcf2e4..11bc75791 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java @@ -23,27 +23,40 @@ import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.termit.dto.AggregatedChangeInfo; import cz.cvut.kbss.termit.dto.PrefixDeclaration; +import cz.cvut.kbss.termit.dto.RdfsStatement; import cz.cvut.kbss.termit.dto.Snapshot; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.event.AssetPersistEvent; import cz.cvut.kbss.termit.event.AssetUpdateEvent; import cz.cvut.kbss.termit.event.RefreshLastModifiedEvent; -import cz.cvut.kbss.termit.model.*; +import cz.cvut.kbss.termit.event.VocabularyRemovalEvent; +import cz.cvut.kbss.termit.model.Glossary; +import cz.cvut.kbss.termit.model.Model; +import cz.cvut.kbss.termit.model.Term; +import cz.cvut.kbss.termit.model.User; +import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.changetracking.AbstractChangeRecord; import cz.cvut.kbss.termit.model.changetracking.PersistChangeRecord; import cz.cvut.kbss.termit.model.changetracking.UpdateChangeRecord; import cz.cvut.kbss.termit.model.resource.Document; import cz.cvut.kbss.termit.model.resource.File; +import cz.cvut.kbss.termit.model.util.EntityToOwlClassMapper; import cz.cvut.kbss.termit.persistence.context.DescriptorFactory; +import cz.cvut.kbss.termit.util.Constants; import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.vocabulary.RDF; import org.eclipse.rdf4j.repository.Repository; import org.eclipse.rdf4j.repository.RepositoryConnection; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; +import org.mockito.Spy; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; @@ -54,7 +67,17 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.*; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -62,7 +85,11 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.lessThanOrEqualTo; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; @@ -75,6 +102,7 @@ class VocabularyDaoTest extends BaseDaoTestRunner { @Autowired private DescriptorFactory descriptorFactory; + @Spy @Autowired private ApplicationEventPublisher eventPublisher; @@ -667,7 +695,7 @@ void resolvePrefixRetrievesPrefixDeclarationForVocabulary() { } @Test - void forceRemoveRemovesVocabularyGlossaryModelAndAllTerms() { + void removeVocabularyRemovesVocabularyGlossaryModelAndAllTermsWithoutDocument() { final Vocabulary vocabulary = Generator.generateVocabularyWithId(); final List terms = IntStream.range(0, 10).mapToObj(i -> Generator.generateTermWithId(vocabulary.getUri())) .toList(); @@ -682,27 +710,71 @@ void forceRemoveRemovesVocabularyGlossaryModelAndAllTerms() { }); }); - transactional(() -> sut.forceRemove(vocabulary)); + transactional(() -> sut.removeVocabulary(vocabulary, false)); final String query = "ASK { ?x a ?type }"; + // vocabulary removed assertFalse(em.createNativeQuery(query, Boolean.class) .setParameter("type", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_c_slovnik)) .getSingleResult()); + // glossary removed assertFalse(em.createNativeQuery(query, Boolean.class) .setParameter("type", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_c_glosar)) .getSingleResult()); + // model removed assertFalse(em.createNativeQuery(query, Boolean.class) .setParameter("type", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_c_model)) .getSingleResult()); + + // all terms removed assertFalse(em.createNativeQuery(query, Boolean.class).setParameter("type", URI.create(SKOS.CONCEPT)) .getSingleResult()); + + // document not removed assertTrue(em.createNativeQuery(query, Boolean.class) .setParameter("type", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_c_dokument)) .getSingleResult()); + + // vocabulary removed from cache assertFalse(em.getEntityManagerFactory().getCache().contains(Vocabulary.class, vocabulary.getUri(), descriptorFactory.vocabularyDescriptor( vocabulary))); } + @Test + void removePublishesEventAndDropsGraph() { + final Vocabulary vocabulary = Generator.generateVocabularyWithId(); + final List terms = IntStream.range(0, 10).mapToObj(i -> Generator.generateTermWithId(vocabulary.getUri())) + .toList(); + final Document doc = Generator.generateDocumentWithId(); + vocabulary.setDocument(doc); + transactional(() -> { + em.persist(vocabulary, descriptorFor(vocabulary)); + em.persist(doc, descriptorFactory.documentDescriptor(vocabulary)); + terms.forEach(t -> { + em.persist(t, descriptorFactory.termDescriptor(t)); + Generator.addTermInVocabularyRelationship(t, vocabulary.getUri(), em); + }); + }); + + assertTrue(em.createNativeQuery("ASK WHERE { GRAPH ?vocabulary { ?s ?p ?o }}", Boolean.class) + .setParameter("vocabulary", vocabulary.getUri()) + .getSingleResult()); + + transactional(() -> sut.remove(vocabulary)); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(VocabularyRemovalEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + VocabularyRemovalEvent event = eventCaptor.getValue(); + assertNotNull(event); + + assertEquals(event.getVocabulary(), vocabulary.getUri()); + + assertFalse(em.createNativeQuery("ASK WHERE{ GRAPH ?vocabulary { ?s ?p ?o }}", Boolean.class) + .setParameter("vocabulary", vocabulary.getUri()) + .getSingleResult()); + } + @Test void persistPersistsDocumentWhenItDoesNotExist() { final Vocabulary instance = Generator.generateVocabularyWithId(); @@ -717,4 +789,143 @@ void persistPersistsDocumentWhenItDoesNotExist() { assertEquals(document, em.find(Document.class, document.getUri(), descriptorFactory.documentDescriptor(instance))); } + + @Test + void internalAddRealtionCreatesRelation() { + final Vocabulary vocabulary = Generator.generateVocabularyWithId(); + final Vocabulary secondVocabulary = Generator.generateVocabularyWithId(); + final URI relation = URI.create(SKOS.RELATED); + + transactional(() -> { + sut.persist(vocabulary); + sut.persist(secondVocabulary); + + Generator.addRelation(vocabulary.getUri(), relation, secondVocabulary.getUri(), em); + }); + + Boolean result = em.createNativeQuery(""" + ASK WHERE { + ?vocabulary a ?vocabularyType . + ?secondVocabulary a ?vocabularyType . + ?vocabulary ?relation ?secondVocabulary . + } + """, Boolean.class) + .setParameter("vocabulary", vocabulary.getUri()) + .setParameter("secondVocabulary", secondVocabulary.getUri()) + .setParameter("vocabularyType", URI.create(EntityToOwlClassMapper.getOwlClassForEntity(Vocabulary.class))) + .setParameter("relation", relation).getSingleResult(); + + assertTrue(result); + } + + public static Set skosConceptMatchRelationshipsSource() { + return Constants.SKOS_CONCEPT_MATCH_RELATIONSHIPS; + } + + /** + * term - relation - secondTerm + */ + @ParameterizedTest + @MethodSource("cz.cvut.kbss.termit.persistence.dao.VocabularyDaoTest#skosConceptMatchRelationshipsSource") + void getAnyExternalRelationsReturnsTermsWithOutgoingRelations(URI termRelation) { + final Vocabulary vocabulary = Generator.generateVocabularyWithId(); + final Vocabulary secondVocabulary = Generator.generateVocabularyWithId(); + final Term term = Generator.generateTermWithId(vocabulary.getUri()); + final Term secondTerm = Generator.generateTermWithId(secondVocabulary.getUri()); + + transactional(() -> { + sut.persist(vocabulary); + sut.persist(secondVocabulary); + + em.persist(term, descriptorFactory.termDescriptor(term)); + Generator.addTermInVocabularyRelationship(term, vocabulary.getUri(), em); + + em.persist(secondTerm, descriptorFactory.termDescriptor(secondTerm)); + Generator.addTermInVocabularyRelationship(secondTerm, secondVocabulary.getUri(), em); + + Generator.addRelation(term.getUri(), termRelation, secondTerm.getUri(), em); + }); + + final List relations = sut.getTermRelations(vocabulary); + + assertEquals(1, relations.size()); + final RdfsStatement relation = relations.get(0); + assertEquals(term.getUri(), relation.getObject()); + assertEquals(termRelation, relation.getRelation()); + assertEquals(secondTerm.getUri(), relation.getSubject()); + } + + /** + * secondTerm - relation - term + */ + @ParameterizedTest + @MethodSource("cz.cvut.kbss.termit.persistence.dao.VocabularyDaoTest#skosConceptMatchRelationshipsSource") + void getAnyExternalRelationsReturnsTermsWithIncommingRelations(URI termRelation) { + final Vocabulary vocabulary = Generator.generateVocabularyWithId(); + final Vocabulary secondVocabulary = Generator.generateVocabularyWithId(); + final Term term = Generator.generateTermWithId(vocabulary.getUri()); + final Term secondTerm = Generator.generateTermWithId(secondVocabulary.getUri()); + + transactional(() -> { + sut.persist(vocabulary); + sut.persist(secondVocabulary); + + em.persist(term, descriptorFactory.termDescriptor(term)); + Generator.addTermInVocabularyRelationship(term, vocabulary.getUri(), em); + + em.persist(secondTerm, descriptorFactory.termDescriptor(secondTerm)); + Generator.addTermInVocabularyRelationship(secondTerm, secondVocabulary.getUri(), em); + + Generator.addRelation(secondTerm.getUri(), termRelation, term.getUri(), em); + }); + + final List relations = sut.getTermRelations(vocabulary); + + assertEquals(1, relations.size()); + final RdfsStatement relation = relations.get(0); + assertEquals(secondTerm.getUri(), relation.getObject()); + assertEquals(termRelation, relation.getRelation()); + assertEquals(term.getUri(), relation.getSubject()); + } + + /** + * secondTerm - relation - term
+ * term - relation - secondTerm + */ + @ParameterizedTest + @MethodSource("cz.cvut.kbss.termit.persistence.dao.VocabularyDaoTest#skosConceptMatchRelationshipsSource") + void getAnyExternalRelationsReturnsTermsWithBothRelations(URI termRelation) { + final Vocabulary vocabulary = Generator.generateVocabularyWithId(); + final Vocabulary secondVocabulary = Generator.generateVocabularyWithId(); + final Term term = Generator.generateTermWithId(vocabulary.getUri()); + final Term secondTerm = Generator.generateTermWithId(secondVocabulary.getUri()); + + transactional(() -> { + sut.persist(vocabulary); + sut.persist(secondVocabulary); + + em.persist(term, descriptorFactory.termDescriptor(term)); + Generator.addTermInVocabularyRelationship(term, vocabulary.getUri(), em); + + em.persist(secondTerm, descriptorFactory.termDescriptor(secondTerm)); + Generator.addTermInVocabularyRelationship(secondTerm, secondVocabulary.getUri(), em); + + Generator.addRelation(secondTerm.getUri(), termRelation, term.getUri(), em); + Generator.addRelation(term.getUri(), termRelation, secondTerm.getUri(), em); + }); + + final List relations = sut.getTermRelations(vocabulary); + + assertEquals(2, relations.size()); + relations.forEach(relation -> { + assertEquals(termRelation, relation.getRelation()); + if(relation.getObject().equals(term.getUri())) { + assertEquals(secondTerm.getUri(), relation.getSubject()); + } else if(relation.getObject().equals(secondTerm.getUri())) { + assertEquals(term.getUri(), relation.getSubject()); + } else { + Assertions.fail("The Relation object is neither a term nor a secondTerm"); + } + }); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index 962756539..e7cf45dc6 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -262,13 +262,32 @@ void getByIdUsesSpecifiedNamespaceInsteadOfDefaultOneForResolvingIdentifier() th verify(idResolverMock).resolveIdentifier(namespace, fragment); } + @Test + void removeVocabularyCallsRemove() throws Exception { + final Vocabulary vocabulary = Generator.generateVocabulary(); + vocabulary.setUri(VOCABULARY_URI); + + when(idResolverMock.resolveIdentifier(configMock.getNamespace().getVocabulary(), FRAGMENT)) + .thenReturn(VOCABULARY_URI); + when(serviceMock.findRequired(VOCABULARY_URI)).thenReturn(vocabulary); + + mockMvc.perform(delete(PATH + "/" + FRAGMENT)).andExpect(status().is2xxSuccessful()).andReturn(); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(Vocabulary.class); + verify(serviceMock).remove(captor.capture()); + + assertEquals(vocabulary, captor.getValue()); + // ensures that the object was really a Vocabulary, and not a dto + assertEquals(Vocabulary.class, captor.getValue().getClass()); + } + @Test void removeVocabularyReturns2xxForEmptyVocabulary() throws Exception { final Vocabulary vocabulary = Generator.generateVocabulary(); vocabulary.setUri(VOCABULARY_URI); when(idResolverMock.resolveIdentifier(configMock.getNamespace().getVocabulary(), FRAGMENT)) .thenReturn(VOCABULARY_URI); - when(serviceMock.find(VOCABULARY_URI)).thenReturn(Optional.of(vocabulary)); + when(serviceMock.findRequired(VOCABULARY_URI)).thenReturn(vocabulary); mockMvc.perform(delete(PATH + "/" + FRAGMENT)).andExpect(status().is2xxSuccessful()).andReturn(); } @@ -281,7 +300,7 @@ void removeVocabularyReturns4xxForNotRemovableVocabulary() throws Exception { vocabulary.setUri(VOCABULARY_URI); when(idResolverMock.resolveIdentifier(configMock.getNamespace().getVocabulary(), FRAGMENT)) .thenReturn(VOCABULARY_URI); - when(serviceMock.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + when(serviceMock.findRequired(vocabulary.getUri())).thenReturn(vocabulary); final String fragment = IdentifierResolver.extractIdentifierFragment(vocabulary.getUri()); mockMvc.perform(delete(PATH + "/" + fragment)).andExpect(status().is4xxClientError()).andReturn(); } diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryServiceTest.java index 229916b2a..081852249 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryServiceTest.java @@ -20,6 +20,7 @@ import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jopa.model.descriptors.Descriptor; +import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.exception.*; @@ -38,7 +39,12 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Spy; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; @@ -48,10 +54,13 @@ import java.time.temporal.ChronoUnit; import java.util.Collection; import java.util.Collections; +import java.util.Set; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class VocabularyRepositoryServiceTest extends BaseServiceTestRunner { @@ -64,7 +73,7 @@ class VocabularyRepositoryServiceTest extends BaseServiceTestRunner { @Autowired private EntityManager em; - @Autowired + @SpyBean private VocabularyRepositoryService sut; private UserAccount user; @@ -182,6 +191,51 @@ void removeRemovesEmptyVocabulary() { assertNull(result); } + @Test + void removeThrowsWhenVocabularyIsImported() { + final Vocabulary vocabulary = Generator.generateVocabularyWithId(); + final Vocabulary importing = Generator.generateVocabularyWithId(); + importing.setImportedVocabularies(Set.of(vocabulary.getUri())); + transactional(() -> { + em.persist(vocabulary, descriptorFor(vocabulary)); + em.persist(importing, descriptorFor(importing)); + }); + + assertThrows(AssetRemovalException.class, ()-> sut.remove(vocabulary)); + + // ensure nothing was deleted + final Vocabulary v = em.find(Vocabulary.class, vocabulary.getUri()); + final Vocabulary i = em.find(Vocabulary.class, importing.getUri()); + + assertNotNull(v); + assertNotNull(i); + } + + /** + * @see cz.cvut.kbss.termit.util.Constants#SKOS_CONCEPT_MATCH_RELATIONSHIPS + */ + @ParameterizedTest + @MethodSource("cz.cvut.kbss.termit.persistence.dao.VocabularyDaoTest#skosConceptMatchRelationshipsSource") + void removeThrowsWhenTermRelationExists(URI relation) { + final Vocabulary vocabulary = Generator.generateVocabularyWithId(); + final Vocabulary secondVocabulary = Generator.generateVocabularyWithId(); + + final Term objectTerm = Generator.generateTermWithId(vocabulary.getUri()); + final Term subjectTerm = Generator.generateTermWithId(secondVocabulary.getUri()); + + transactional(()->{ + em.persist(vocabulary, descriptorFor(vocabulary)); + em.persist(secondVocabulary, descriptorFor(secondVocabulary)); + em.persist(objectTerm, descriptorFactory.termDescriptor(objectTerm)); + em.persist(subjectTerm, descriptorFactory.termDescriptor(subjectTerm)); + + Generator.addRelation(subjectTerm.getUri(), relation, objectTerm.getUri(), em); + }); + + assertThrows(AssetRemovalException.class, ()->sut.remove(vocabulary)); + } + + @Test void updateThrowsVocabularyImportExceptionWhenTryingToDeleteVocabularyImportRelationshipAndTermsAreStillRelated() { final Vocabulary subjectVocabulary = Generator.generateVocabularyWithId(); From fa79893aed24f1e4e2829dd48968b6a6822b4ee1 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 12 Aug 2024 09:21:54 +0200 Subject: [PATCH 031/150] PR fixup --- ...java => VocabularyWillBeRemovedEvent.java} | 8 ++--- .../termit/persistence/dao/VocabularyDao.java | 25 ++++++++++++---- .../persistence/dao/skos/SKOSImporter.java | 2 +- .../service/business/ResourceService.java | 4 +-- .../service/business/VocabularyService.java | 13 +++++++-- .../VocabularyRepositoryService.java | 2 +- .../kbss/termit/environment/Environment.java | 20 ++++++++++++- .../kbss/termit/environment/Generator.java | 16 ---------- .../persistence/dao/VocabularyDaoTest.java | 19 ++++++------ .../VocabularyRepositoryServiceTest.java | 29 ++++++++++++------- 10 files changed, 83 insertions(+), 55 deletions(-) rename src/main/java/cz/cvut/kbss/termit/event/{VocabularyRemovalEvent.java => VocabularyWillBeRemovedEvent.java} (52%) diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyRemovalEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java similarity index 52% rename from src/main/java/cz/cvut/kbss/termit/event/VocabularyRemovalEvent.java rename to src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java index a5470691c..3fed1f16e 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyRemovalEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java @@ -1,18 +1,16 @@ package cz.cvut.kbss.termit.event; -import cz.cvut.kbss.termit.model.Vocabulary; import org.springframework.context.ApplicationEvent; import java.net.URI; /** * Indicates that a Vocabulary will be removed - *

- * Fired in {@link cz.cvut.kbss.termit.persistence.dao.VocabularyDao#remove(Vocabulary)} right before the vocabulary removal. */ -public class VocabularyRemovalEvent extends ApplicationEvent { +public class VocabularyWillBeRemovedEvent extends ApplicationEvent { private final URI vocabulary; - public VocabularyRemovalEvent(Object source, URI vocabulary) { + + public VocabularyWillBeRemovedEvent(Object source, URI vocabulary) { super(source); this.vocabulary = vocabulary; } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index 7413e619d..1f04fe548 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -30,7 +30,7 @@ import cz.cvut.kbss.termit.event.AssetPersistEvent; import cz.cvut.kbss.termit.event.AssetUpdateEvent; import cz.cvut.kbss.termit.event.RefreshLastModifiedEvent; -import cz.cvut.kbss.termit.event.VocabularyRemovalEvent; +import cz.cvut.kbss.termit.event.VocabularyWillBeRemovedEvent; import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.model.Glossary; import cz.cvut.kbss.termit.model.Term; @@ -217,19 +217,34 @@ public Vocabulary update(Vocabulary entity) { * This deletes the whole graph of the vocabulary, all terms in the vocabulary's glossary and then removes the vocabulary itself. Extreme caution * should be exercised when using this method. All relevant data, including documents and files, will be dropped. *

- * Publishes {@link VocabularyRemovalEvent} before the actual removal to allow other services to clean up related resources (e.g., delete the document). + * Publishes {@link VocabularyWillBeRemovedEvent} before the actual removal to allow other services to clean up related resources (e.g., delete the document). * @param entity The vocabulary to delete */ @ModifiesData @Override public void remove(Vocabulary entity) { - eventPublisher.publishEvent(new VocabularyRemovalEvent(this, entity.getUri())); + eventPublisher.publishEvent(new VocabularyWillBeRemovedEvent(this, entity.getUri())); this.removeVocabulary(entity, true); } /** + * Does not publish the {@link VocabularyWillBeRemovedEvent}. *

- * Does not publishes the {@link VocabularyRemovalEvent}.
+ * Forcefully removes the specified vocabulary. + *

+ * This deletes all terms in the vocabulary's glossary and then removes the vocabulary itself. + * Extreme caution should be exercised when using this method, + * as it does not check for any references or usage and just drops all the relevant data. + *

+ * The document is not removed. + */ + public void removeVocabularyKeepDocument(Vocabulary entity) { + this.removeVocabulary(entity, false); + } + + /** + *

+ * Does not publish the {@link VocabularyWillBeRemovedEvent}.
* You should use {@link #remove(Vocabulary)} instead. *

* Forcefully removes the specified vocabulary. @@ -243,7 +258,7 @@ public void remove(Vocabulary entity) { * their relations, model, glossary and vocabulary itself, keeps the document. * When true, the whole vocabulary graph is dropped. */ - public void removeVocabulary(Vocabulary entity, boolean dropGraph) { + private void removeVocabulary(Vocabulary entity, boolean dropGraph) { Objects.requireNonNull(entity); LOG.debug("Forcefully removing vocabulary {} and all its contents.", entity); try { diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java index 04150b78c..715caf141 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSImporter.java @@ -206,7 +206,7 @@ private void clearVocabulary(Vocabulary newVocabulary) { possibleVocabulary.ifPresent(toRemove -> { newVocabulary.setDocument(toRemove.getDocument()); newVocabulary.setAcl(toRemove.getAcl()); - vocabularyDao.removeVocabulary(toRemove, false); + vocabularyDao.removeVocabularyKeepDocument(toRemove); }); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java index 8e5ddef58..faa633d75 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java @@ -20,7 +20,7 @@ import cz.cvut.kbss.termit.asset.provenance.SupportsLastModification; import cz.cvut.kbss.termit.event.DocumentRenameEvent; import cz.cvut.kbss.termit.event.FileRenameEvent; -import cz.cvut.kbss.termit.event.VocabularyRemovalEvent; +import cz.cvut.kbss.termit.event.VocabularyWillBeRemovedEvent; import cz.cvut.kbss.termit.exception.InvalidParameterException; import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.exception.UnsupportedAssetOperationException; @@ -97,7 +97,7 @@ public ResourceService(ResourceRepositoryService repositoryService, DocumentMana * Ensures that document gets removed during Vocabulary removal */ @EventListener - public void onVocabularyRemoval(VocabularyRemovalEvent event) { + public void onVocabularyRemoval(VocabularyWillBeRemovedEvent event) { vocabularyService.find(event.getVocabulary()).ifPresent(vocabulary -> { if(vocabulary.getDocument() != null) { remove(vocabulary.getDocument()); diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 7e631ceeb..0283abc48 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -56,7 +56,14 @@ import java.net.URI; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import static cz.cvut.kbss.termit.util.Constants.VOCABULARY_REMOVAL_IGNORED_RELATIONS; @@ -188,12 +195,12 @@ public Set getRelatedVocabularies(Vocabulary entity) { return repositoryService.getRelatedVocabularies(entity); } - @PreAuthorize("@vocabularyAuthorizationService.canRead(#entity)") + @PreAuthorize("@vocabularyAuthorizationService.canRead(#vocabulary)") public List getTermRelations(Vocabulary vocabulary) { return repositoryService.getTermRelations(vocabulary); } - @PreAuthorize("@vocabularyAuthorizationService.canRead(#entity)") + @PreAuthorize("@vocabularyAuthorizationService.canRead(#vocabulary)") public List getVocabularyRelations(Vocabulary vocabulary) { return repositoryService.getVocabularyRelations(vocabulary, VOCABULARY_REMOVAL_IGNORED_RELATIONS); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index dd006d9ed..e0caedcb3 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -289,7 +289,7 @@ public void remove(Vocabulary instance) { * @param instance The instance to be removed, not {@code null} */ @Override - protected void preRemove(Vocabulary instance) { + protected void preRemove(@NotNull Vocabulary instance) { ensureNotImported(instance); ensureNoTermRelationsExists(instance); super.preRemove(instance); diff --git a/src/test/java/cz/cvut/kbss/termit/environment/Environment.java b/src/test/java/cz/cvut/kbss/termit/environment/Environment.java index 3b76b438f..e9db7449e 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/Environment.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/Environment.java @@ -29,10 +29,10 @@ import cz.cvut.kbss.termit.model.UserAccount; import cz.cvut.kbss.termit.security.model.AuthenticationToken; import cz.cvut.kbss.termit.security.model.TermItUserDetails; -import cz.cvut.kbss.termit.service.security.SecurityUtils; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Vocabulary; +import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.repository.Repository; import org.eclipse.rdf4j.repository.RepositoryConnection; import org.eclipse.rdf4j.rio.RDFFormat; @@ -49,6 +49,7 @@ import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashSet; @@ -219,4 +220,21 @@ public static void injectConfiguration(cz.cvut.kbss.termit.model.Vocabulary voca throw new RuntimeException("Unable to inject configuration into Vocabulary instance.", e); } } + + /** + * Adds relation into entity manager connection + * + * @implNote call in transactional + */ + public static void addRelation(URI subject, URI predicate, URI object, EntityManager em) { + final Repository repo = em.unwrap(Repository.class); + try (RepositoryConnection conn = repo.getConnection()) { + final ValueFactory vf = conn.getValueFactory(); + conn.begin(); + conn.add(vf.createIRI(subject.toString()), + vf.createIRI(predicate.toString()), + vf.createIRI(object.toString())); + conn.commit(); + } + } } diff --git a/src/test/java/cz/cvut/kbss/termit/environment/Generator.java b/src/test/java/cz/cvut/kbss/termit/environment/Generator.java index 47ea16cf2..baaecda1b 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/Generator.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/Generator.java @@ -527,20 +527,4 @@ public static List> generateAccessControlRecords() { result.add(rr); return result; } - - /** - * Adds relation into entity manager connection - * @implNote call in transactional - */ - public static void addRelation(URI subject, URI predicate, URI object, EntityManager em) { - final Repository repo = em.unwrap(Repository.class); - try (RepositoryConnection conn = repo.getConnection()) { - final ValueFactory vf = conn.getValueFactory(); - conn.begin(); - conn.add(vf.createIRI(subject.toString()), - vf.createIRI(predicate.toString()), - vf.createIRI(object.toString())); - conn.commit(); - } - } } diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java index 11bc75791..5655a6011 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java @@ -30,7 +30,7 @@ import cz.cvut.kbss.termit.event.AssetPersistEvent; import cz.cvut.kbss.termit.event.AssetUpdateEvent; import cz.cvut.kbss.termit.event.RefreshLastModifiedEvent; -import cz.cvut.kbss.termit.event.VocabularyRemovalEvent; +import cz.cvut.kbss.termit.event.VocabularyWillBeRemovedEvent; import cz.cvut.kbss.termit.model.Glossary; import cz.cvut.kbss.termit.model.Model; import cz.cvut.kbss.termit.model.Term; @@ -54,7 +54,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Spy; import org.springframework.beans.factory.annotation.Autowired; @@ -710,7 +709,7 @@ void removeVocabularyRemovesVocabularyGlossaryModelAndAllTermsWithoutDocument() }); }); - transactional(() -> sut.removeVocabulary(vocabulary, false)); + transactional(() -> sut.removeVocabularyKeepDocument(vocabulary)); final String query = "ASK { ?x a ?type }"; // vocabulary removed assertFalse(em.createNativeQuery(query, Boolean.class) @@ -762,10 +761,10 @@ void removePublishesEventAndDropsGraph() { transactional(() -> sut.remove(vocabulary)); - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(VocabularyRemovalEvent.class); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(VocabularyWillBeRemovedEvent.class); verify(eventPublisher).publishEvent(eventCaptor.capture()); - VocabularyRemovalEvent event = eventCaptor.getValue(); + VocabularyWillBeRemovedEvent event = eventCaptor.getValue(); assertNotNull(event); assertEquals(event.getVocabulary(), vocabulary.getUri()); @@ -800,7 +799,7 @@ void internalAddRealtionCreatesRelation() { sut.persist(vocabulary); sut.persist(secondVocabulary); - Generator.addRelation(vocabulary.getUri(), relation, secondVocabulary.getUri(), em); + Environment.addRelation(vocabulary.getUri(), relation, secondVocabulary.getUri(), em); }); Boolean result = em.createNativeQuery(""" @@ -843,7 +842,7 @@ void getAnyExternalRelationsReturnsTermsWithOutgoingRelations(URI termRelation) em.persist(secondTerm, descriptorFactory.termDescriptor(secondTerm)); Generator.addTermInVocabularyRelationship(secondTerm, secondVocabulary.getUri(), em); - Generator.addRelation(term.getUri(), termRelation, secondTerm.getUri(), em); + Environment.addRelation(term.getUri(), termRelation, secondTerm.getUri(), em); }); final List relations = sut.getTermRelations(vocabulary); @@ -876,7 +875,7 @@ void getAnyExternalRelationsReturnsTermsWithIncommingRelations(URI termRelation) em.persist(secondTerm, descriptorFactory.termDescriptor(secondTerm)); Generator.addTermInVocabularyRelationship(secondTerm, secondVocabulary.getUri(), em); - Generator.addRelation(secondTerm.getUri(), termRelation, term.getUri(), em); + Environment.addRelation(secondTerm.getUri(), termRelation, term.getUri(), em); }); final List relations = sut.getTermRelations(vocabulary); @@ -910,8 +909,8 @@ void getAnyExternalRelationsReturnsTermsWithBothRelations(URI termRelation) { em.persist(secondTerm, descriptorFactory.termDescriptor(secondTerm)); Generator.addTermInVocabularyRelationship(secondTerm, secondVocabulary.getUri(), em); - Generator.addRelation(secondTerm.getUri(), termRelation, term.getUri(), em); - Generator.addRelation(term.getUri(), termRelation, secondTerm.getUri(), em); + Environment.addRelation(secondTerm.getUri(), termRelation, term.getUri(), em); + Environment.addRelation(term.getUri(), termRelation, secondTerm.getUri(), em); }); final List relations = sut.getTermRelations(vocabulary); diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryServiceTest.java index 081852249..16937cfe1 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryServiceTest.java @@ -20,10 +20,14 @@ import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jopa.model.descriptors.Descriptor; -import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; -import cz.cvut.kbss.termit.exception.*; +import cz.cvut.kbss.termit.exception.AssetRemovalException; +import cz.cvut.kbss.termit.exception.NotFoundException; +import cz.cvut.kbss.termit.exception.ResourceExistsException; +import cz.cvut.kbss.termit.exception.SnapshotNotEditableException; +import cz.cvut.kbss.termit.exception.TermItException; +import cz.cvut.kbss.termit.exception.ValidationException; import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.UserAccount; @@ -41,10 +45,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.Spy; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; @@ -57,10 +58,16 @@ import java.util.Set; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class VocabularyRepositoryServiceTest extends BaseServiceTestRunner { @@ -73,7 +80,7 @@ class VocabularyRepositoryServiceTest extends BaseServiceTestRunner { @Autowired private EntityManager em; - @SpyBean + @Autowired private VocabularyRepositoryService sut; private UserAccount user; @@ -229,7 +236,7 @@ void removeThrowsWhenTermRelationExists(URI relation) { em.persist(objectTerm, descriptorFactory.termDescriptor(objectTerm)); em.persist(subjectTerm, descriptorFactory.termDescriptor(subjectTerm)); - Generator.addRelation(subjectTerm.getUri(), relation, objectTerm.getUri(), em); + Environment.addRelation(subjectTerm.getUri(), relation, objectTerm.getUri(), em); }); assertThrows(AssetRemovalException.class, ()->sut.remove(vocabulary)); From 47817b55589855c09210f7df976dd9e877acc2c7 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 12 Aug 2024 14:40:37 +0200 Subject: [PATCH 032/150] [Fix] Prevent ConcurrentModificationException in ScheduledContextRemover. The exception was likely caused by adding to the collection while it was being iterated during context removal. --- .../termit/persistence/dao/util/ScheduledContextRemover.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java index fb82b4228..326fe0ab0 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java @@ -54,7 +54,7 @@ public synchronized void scheduleForRemoval(@NonNull URI contextUri) { */ @Transactional @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) - public void runContextRemoval() { + public synchronized void runContextRemoval() { LOG.trace("Running scheduled repository context removal."); contextsToRemove.forEach(g -> { LOG.trace("Dropping repository context {}.", Utils.uriToString(g)); From 5c9799796263fef7a3c15fb06928a4d7409a4ece Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 12 Aug 2024 16:20:44 +0200 Subject: [PATCH 033/150] [kbss-cvut/termit-ui#449] Ensure existing vocabulary can be reimported. This means remove existing terms that are to be imported again to prevent data conflicts. --- .../termit/service/business/TermService.java | 26 +++++++----- .../service/importer/excel/ExcelImporter.java | 12 +++++- .../repository/TermRepositoryService.java | 41 ++++++++++--------- .../importer/excel/ExcelImporterTest.java | 32 +++++++++++++++ 4 files changed, 80 insertions(+), 31 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index 624e7be13..8840e21a7 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -46,6 +46,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Pageable; +import org.springframework.lang.NonNull; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; @@ -303,8 +304,8 @@ public Term findRequired(URI id) { /** * Gets a reference to a Term with the specified identifier. *

- * Note that this method is not protected by ACL-based authorization and should thus not be used without some - * other type of authorization. + * Note that this method is not protected by ACL-based authorization and should thus not be used without some other + * type of authorization. * * @param id Term identifier * @return Matching Term reference wrapped in an {@code Optional} @@ -323,12 +324,12 @@ public Term getReference(URI id) { public List findSubTerms(Term parent) { Objects.requireNonNull(parent); return parent.getSubTerms() == null ? Collections.emptyList() : - parent.getSubTerms().stream().map(u -> repositoryService.find(u.getUri()).orElseThrow( - () -> new NotFoundException( - "Child of term " + parent + " with id " + u.getUri() + " not found!"))) - .sorted(Comparator.comparing((Term t) -> t.getLabel().get(config.getPersistence().getLanguage()), - Comparator.nullsLast(Comparator.naturalOrder()))) - .collect(Collectors.toList()); + parent.getSubTerms().stream().map(u -> repositoryService.find(u.getUri()).orElseThrow( + () -> new NotFoundException( + "Child of term " + parent + " with id " + u.getUri() + " not found!"))) + .sorted(Comparator.comparing((Term t) -> t.getLabel().get(config.getPersistence().getLanguage()), + Comparator.nullsLast(Comparator.naturalOrder()))) + .collect(Collectors.toList()); } /** @@ -418,7 +419,7 @@ public Term update(Term term) { * @param term Term to remove */ @PreAuthorize("@termAuthorizationService.canRemove(#term)") - public void remove(Term term) { + public void remove(@NonNull Term term) { Objects.requireNonNull(term); repositoryService.remove(term); } @@ -518,14 +519,17 @@ public void setState(Term term, URI state) { private void checkForInvalidTerminalStateAssignment(Term term, URI state) { final List states = languageService.getTermStates(); final Predicate isStateTerminal = (URI s) -> states.stream().filter(r -> r.getUri().equals(s)).findFirst() - .map(r -> r.hasType(cz.cvut.kbss.termit.util.Vocabulary.s_c_koncovy_stav_pojmu)) + .map(r -> r.hasType( + cz.cvut.kbss.termit.util.Vocabulary.s_c_koncovy_stav_pojmu)) .orElse(false); if (!isStateTerminal.test(state)) { return; } if (Utils.emptyIfNull(term.getSubTerms()).stream() .anyMatch(Predicate.not(ti -> isStateTerminal.test(ti.getState())))) { - throw new InvalidTermStateException("Cannot set state of term " + term + " to terminal when at least one of its sub-terms is not in terminal state.", "error.term.state.terminal.liveChildren"); + throw new InvalidTermStateException( + "Cannot set state of term " + term + " to terminal when at least one of its sub-terms is not in terminal state.", + "error.term.state.terminal.liveChildren"); } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index 9fe0c2b17..acb3dc4da 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -1,5 +1,6 @@ package cz.cvut.kbss.termit.service.importer.excel; +import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.exception.importing.VocabularyDoesNotExistException; import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; @@ -53,13 +54,16 @@ public class ExcelImporter implements VocabularyImporter { private final IdentifierResolver idResolver; private final Configuration config; + private final EntityManager em; + public ExcelImporter(VocabularyDao vocabularyDao, TermRepositoryService termService, DataDao dataDao, - IdentifierResolver idResolver, Configuration config) { + IdentifierResolver idResolver, Configuration config, EntityManager em) { this.vocabularyDao = vocabularyDao; this.termService = termService; this.dataDao = dataDao; this.idResolver = idResolver; this.config = config; + this.em = em; } @Override @@ -90,6 +94,12 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) terms = sheetImporter.resolveTermsFromSheet(sheet); rawDataToInsert.addAll(sheetImporter.getRawDataToInsert()); } + terms.stream().filter(t -> termService.exists(t.getUri())).forEach(t -> { + LOG.trace("Term {} already exists. Removing old version.", t); + termService.forceRemove(termService.findRequired(t.getUri())); + // Flush changes to prevent EntityExistsExceptions when term is already managed in PC as different type (Term vs TermInfo) + em.flush(); + }); // Ensure all parents are saved before we start adding children terms.stream().filter(t -> Utils.emptyIfNull(t.getParentTerms()).isEmpty()) .forEach(root -> { diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java index 23d5e2171..13715321e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java @@ -36,13 +36,13 @@ import cz.cvut.kbss.termit.service.term.OrphanedInverseTermRelationshipRemover; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; +import jakarta.validation.Validator; import org.apache.jena.vocabulary.SKOS; -import org.jetbrains.annotations.NotNull; import org.springframework.data.domain.Pageable; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import jakarta.validation.Validator; import java.net.URI; import java.time.Instant; import java.util.Collection; @@ -94,13 +94,13 @@ protected TermDto mapToDto(Term entity) { } @Override - public void persist(@NotNull Term instance) { + public void persist(@NonNull Term instance) { throw new UnsupportedOperationException( "Persisting term by itself is not supported. It has to be connected to a vocabulary or a parent term."); } @Override - protected void preUpdate(@NotNull Term instance) { + protected void preUpdate(@NonNull Term instance) { super.preUpdate(instance); // Existence check is done as part of super.preUpdate final Term original = termDao.find(instance.getUri()).get(); @@ -125,7 +125,7 @@ private void pruneEmptyTranslations(Term instance) { } @Override - protected void postUpdate(@NotNull Term instance) { + protected void postUpdate(@NonNull Term instance) { final Vocabulary vocabulary = vocabularyService.getReference(instance.getVocabulary()); if (instance.hasParentInSameVocabulary()) { vocabulary.getGlossary().removeRootTerm(instance); @@ -369,18 +369,6 @@ public List getDefinitionallyRelatedOf(Term instance) { return termOccurrenceDao.findAllDefinitionalOf(instance); } - /** - * Removes a term if it: - does not have children, - is not related to any resource, - is not related to any term - * occurrences. - * - * @param instance the term to be deleted - */ - @Transactional - @Override - public void remove(Term instance) { - super.remove(instance); - } - /** * Checks that a term can be removed. *

@@ -395,7 +383,7 @@ public void remove(Term instance) { * @throws AssetRemovalException If the specified term cannot be removed */ @Override - protected void preRemove(@NotNull Term instance) { + protected void preRemove(@NonNull Term instance) { super.preRemove(instance); final List ai = getOccurrenceInfo(instance); if (!ai.isEmpty()) { @@ -427,7 +415,7 @@ protected void preRemove(@NotNull Term instance) { } @Override - protected void postRemove(@NotNull Term instance) { + protected void postRemove(@NonNull Term instance) { super.postRemove(instance); if (!instance.hasParentInSameVocabulary()) { final Vocabulary v = vocabularyService.findRequired(instance.getVocabulary()); @@ -435,6 +423,21 @@ protected void postRemove(@NotNull Term instance) { } } + /** + * Forcefully removes the specified term instance. + *

+ * It is expected that the file has a form matching either the downloadable template file or the file exported by TermIt + * itself. + *

+ * The importer processes all sheets in the workbook, skipping any sheets that do not match the expected format. The + * format is given by column labels available currently in Czech and English (all other languages should use English + * column labels). It is expected that each sheet contains the same terms in the same order. Sheet name should + * correspond to language in English. Term identifiers may be specified in the sheet, but if they do not correspond to + * the target vocabulary, they will be adjusted. + *

+ * The importer removes any existing terms that appear in the sheet and would thus be overwritten. + *

+ * SKOS relationships can be used in the sheet. If they are within a single vocabulary, terms may be referenced by their + * labels. Relationships to external terms must use full URIs. State and type columns are skipped during import. Also, + * prefixed URIs are supported, as long as the workbook contains a sheet with prefix definitions. + */ @Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class ExcelImporter implements VocabularyImporter { diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 071ac84f8..c135954b6 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -38,7 +38,8 @@ /** * Maps an Excel sheet with terms in one language to TermIt {@link Term}s, possibly reusing already processed terms. *

- * Note that this class keeps a state. + * Note that this class keeps a state and should thus not be reused to process multiple sheets in a single spreadsheet. + * Instead, a new instance should be created for each sheet. */ class LocalizedSheetImporter { @@ -269,7 +270,8 @@ private Term getTerm(String identification) { private void mapSkosMatchProperties(Term subject, String property, Set objects) { final URI propertyUri = URI.create(property); objects.stream().map(id -> URI.create(prefixMap.resolvePrefixed(id))).filter(termRepositoryService::exists) - .forEach(uri -> rawDataToInsert.add(new ExcelImporter.TermRelationship(subject, propertyUri, new Term(uri)))); + .forEach(uri -> rawDataToInsert.add( + new ExcelImporter.TermRelationship(subject, propertyUri, new Term(uri)))); } List getRawDataToInsert() { From e2683ed42d6af543bbbe8079f2cf741260a953d0 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 12 Aug 2024 18:33:16 +0200 Subject: [PATCH 036/150] [kbss-cvut/termit-ui#449] Support resolving term state and types from imported Excel. --- .../service/importer/excel/ExcelImporter.java | 18 ++++++---- .../excel/LocalizedSheetImporter.java | 34 +++++++++++++++--- src/main/resources/attributes/cs.properties | 2 ++ src/main/resources/attributes/en.properties | 2 ++ .../resources/template/termit-import.xlsx | Bin 59064 -> 55991 bytes .../importer/excel/ExcelImporterTest.java | 34 ++++++++++++++++++ .../data/import-with-type-state-en.xlsx | Bin 0 -> 35558 bytes 7 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 src/test/resources/data/import-with-type-state-en.xlsx diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index d87192b4c..6db51b13d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -12,6 +12,7 @@ import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.export.ExcelVocabularyExporter; import cz.cvut.kbss.termit.service.importer.VocabularyImporter; +import cz.cvut.kbss.termit.service.language.LanguageService; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; @@ -50,8 +51,9 @@ * The importer removes any existing terms that appear in the sheet and would thus be overwritten. *

* SKOS relationships can be used in the sheet. If they are within a single vocabulary, terms may be referenced by their - * labels. Relationships to external terms must use full URIs. State and type columns are skipped during import. Also, - * prefixed URIs are supported, as long as the workbook contains a sheet with prefix definitions. + * labels. Relationships to external terms must use full URIs. State and type are resolved via label or identifier, both + * methods are supported. Also, prefixed URIs are supported, as long as the workbook contains a sheet with prefix + * definitions. */ @Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) @@ -69,16 +71,20 @@ public class ExcelImporter implements VocabularyImporter { private final TermRepositoryService termService; private final DataDao dataDao; + private final LanguageService languageService; + private final IdentifierResolver idResolver; private final Configuration config; private final EntityManager em; public ExcelImporter(VocabularyDao vocabularyDao, TermRepositoryService termService, DataDao dataDao, - IdentifierResolver idResolver, Configuration config, EntityManager em) { + LanguageService languageService, IdentifierResolver idResolver, Configuration config, + EntityManager em) { this.vocabularyDao = vocabularyDao; this.termService = termService; this.dataDao = dataDao; + this.languageService = languageService; this.idResolver = idResolver; this.config = config; this.em = em; @@ -107,9 +113,9 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) // Skip already processed prefix sheet continue; } - final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter(termService, prefixMap, - terms, - idResolver, termNamespace); + final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter( + new LocalizedSheetImporter.Services(termService, languageService, idResolver), + prefixMap, terms, termNamespace); terms = sheetImporter.resolveTermsFromSheet(sheet); rawDataToInsert.addAll(sheetImporter.getRawDataToInsert()); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index c135954b6..e6c2c9568 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -5,11 +5,14 @@ import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.jsonld.JsonLd; +import cz.cvut.kbss.termit.dto.RdfsResource; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.export.util.TabularTermExportUtils; +import cz.cvut.kbss.termit.service.language.LanguageService; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.util.Utils; +import cz.cvut.kbss.termit.util.Vocabulary; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -48,6 +51,7 @@ class LocalizedSheetImporter { private static final String FALLBACK_LANGUAGE = "en"; private final TermRepositoryService termRepositoryService; + private final LanguageService languageService; private final PrefixMap prefixMap; private final List existingTerms; private final IdentifierResolver idResolver; @@ -61,13 +65,13 @@ class LocalizedSheetImporter { private final Map idToTerm = new HashMap<>(); private List rawDataToInsert; - LocalizedSheetImporter(TermRepositoryService termRepositoryService, PrefixMap prefixMap, List existingTerms, - IdentifierResolver idResolver, + LocalizedSheetImporter(Services services, PrefixMap prefixMap, List existingTerms, String expectedTermNamespace) { - this.termRepositoryService = termRepositoryService; + this.termRepositoryService = services.termRepositoryService(); + this.languageService = services.languageService(); this.prefixMap = prefixMap; this.existingTerms = existingTerms; - this.idResolver = idResolver; + this.idResolver = services.idResolver(); this.expectedTermNamespace = expectedTermNamespace; existingTerms.stream().filter(t -> t.getUri() != null).forEach(t -> idToTerm.put(t.getUri(), t)); } @@ -203,6 +207,10 @@ private void mapRowToTermAttributes(Term term, Row termRow) { rtm -> mapSkosMatchProperties(term, SKOS.RELATED_MATCH, splitIntoMultipleValues(rtm))); getAttributeValue(termRow, SKOS.EXACT_MATCH).ifPresent( exm -> mapSkosMatchProperties(term, SKOS.EXACT_MATCH, splitIntoMultipleValues(exm))); + getAttributeValue(termRow, JsonLd.TYPE).flatMap(this::resolveTermType).ifPresent(t -> term.setTypes(Set.of(t))); + getAttributeValue(termRow, Vocabulary.s_p_ma_stav_pojmu).flatMap(this::resolveTermState) + .ifPresent(term::setState); + } private MultilingualString initSingularMultilingualString(Supplier getter, @@ -274,6 +282,20 @@ private void mapSkosMatchProperties(Term subject, String property, Set o new ExcelImporter.TermRelationship(subject, propertyUri, new Term(uri)))); } + private Optional resolveTermType(String value) { + return languageService.getTermTypes().stream() + .filter(t -> value.equals(t.getLabel().get(langTag)) || value.equals( + t.getUri().toString())).findFirst() + .map(t -> t.getUri().toString()); + } + + private Optional resolveTermState(String value) { + return languageService.getTermStates().stream() + .filter(t -> value.equals(t.getLabel().get(langTag)) || value.equals( + t.getUri().toString())).findFirst() + .map(RdfsResource::getUri); + } + List getRawDataToInsert() { return rawDataToInsert; } @@ -321,4 +343,8 @@ private static Set splitIntoMultipleValues(String value) { return Stream.of(value.split(TabularTermExportUtils.STRING_DELIMITER)).map(String::trim) .collect(Collectors.toSet()); } + + record Services(TermRepositoryService termRepositoryService, LanguageService languageService, + IdentifierResolver idResolver) { + } } diff --git a/src/main/resources/attributes/cs.properties b/src/main/resources/attributes/cs.properties index daa60bb25..32550e4b7 100644 --- a/src/main/resources/attributes/cs.properties +++ b/src/main/resources/attributes/cs.properties @@ -11,4 +11,6 @@ http\://www.w3.org/2004/02/skos/core#related=Souvisej http\://www.w3.org/2004/02/skos/core#relatedMatch=Externí související pojmy http\://www.w3.org/2004/02/skos/core#exactMatch=Pojmy se stejným významem http\://purl.org/dc/terms/references=Reference +http\://onto.fel.cvut.cz/ontologies/slovn\u00edk/agendov\u00fd/popis-dat/pojem/m\u00e1-stav-pojmu=Stav pojmu @id=Identifikátor +@type=Typ diff --git a/src/main/resources/attributes/en.properties b/src/main/resources/attributes/en.properties index 93ded3cb1..3fe16fab5 100644 --- a/src/main/resources/attributes/en.properties +++ b/src/main/resources/attributes/en.properties @@ -11,4 +11,6 @@ http\://www.w3.org/2004/02/skos/core#related=Related terms http\://www.w3.org/2004/02/skos/core#relatedMatch=Related match terms http\://www.w3.org/2004/02/skos/core#exactMatch=Exact matches http\://purl.org/dc/terms/references=References +http\://onto.fel.cvut.cz/ontologies/slovn\u00edk/agendov\u00fd/popis-dat/pojem/m\u00e1-stav-pojmu=State @id=Identifier +@type=Type diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx index aad4a81f1fdab7e996d0995f167b7539bf10a579..942658ca0edb7f92339d07333df2ede054cfde65 100644 GIT binary patch literal 55991 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 I*! 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 captor = ArgumentCaptor.forClass(Term.class); + verify(termService).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + final Term result = captor.getValue(); + assertEquals(Set.of(type.getUri().toString()), result.getTypes()); + assertEquals(state.getUri(), result.getState()); + } } diff --git a/src/test/resources/data/import-with-type-state-en.xlsx b/src/test/resources/data/import-with-type-state-en.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..517481b3a64e1a22da40391142ad6a833c382a2b GIT binary patch literal 35558 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 literal 0 HcmV?d00001 From 35e4134ecf031f79439a48283cabe31250dd8906 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 13 Aug 2024 16:47:44 +0200 Subject: [PATCH 037/150] [kbss-cvut/termit-ui#449] Fix NPX in ExcelImport. --- .../cvut/kbss/termit/service/importer/excel/ExcelImporter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index 6db51b13d..75d299c5e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -119,7 +119,7 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) terms = sheetImporter.resolveTermsFromSheet(sheet); rawDataToInsert.addAll(sheetImporter.getRawDataToInsert()); } - terms.stream().filter(t -> termService.exists(t.getUri())).forEach(t -> { + terms.stream().filter(t -> t.getUri() != null && termService.exists(t.getUri())).forEach(t -> { LOG.trace("Term {} already exists. Removing old version.", t); termService.forceRemove(termService.findRequired(t.getUri())); // Flush changes to prevent EntityExistsExceptions when term is already managed in PC as different type (Term vs TermInfo) From 8c9d08683a9923ee6fe3ebcd60ff49e5c20764b2 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 13 Aug 2024 16:55:21 +0200 Subject: [PATCH 038/150] [HotFix] Allow disabling automatic text analysis of all vocabulary terms on term edit. The automatic analysis causes issues when multiple users edit a vocabulary at the same time (Perf #285), this is until we have a more permanent solution. --- .../termit/service/business/TermService.java | 16 ++++++++++------ .../cz/cvut/kbss/termit/util/Configuration.java | 10 ++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index b43edbb7e..983f00eb1 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -374,8 +374,10 @@ public void persistRoot(Term term, Vocabulary owner) { Objects.requireNonNull(owner); languageService.getInitialTermState().ifPresent(is -> term.setState(is.getUri())); repositoryService.addRootTermToVocabulary(term, owner); - analyzeTermDefinition(term, owner.getUri()); - vocabularyService.runTextAnalysisOnAllTerms(owner); + if (!config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) { + analyzeTermDefinition(term, owner.getUri()); + vocabularyService.runTextAnalysisOnAllTerms(owner); + } } /** @@ -390,8 +392,10 @@ public void persistChild(Term child, Term parent) { Objects.requireNonNull(parent); languageService.getInitialTermState().ifPresent(is -> child.setState(is.getUri())); repositoryService.addChildTerm(child, parent); - analyzeTermDefinition(child, parent.getVocabulary()); - vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(parent.getVocabulary())); + if (!config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) { + analyzeTermDefinition(child, parent.getVocabulary()); + vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(parent.getVocabulary())); + } } /** @@ -408,10 +412,10 @@ public Term update(Term term) { checkForInvalidTerminalStateAssignment(original, term.getState()); // Ensure the change is merged into the repo before analyzing other terms final Term result = repositoryService.update(term); - if (!Objects.equals(original.getDefinition(), term.getDefinition())) { + if (!Objects.equals(original.getDefinition(), term.getDefinition()) && !config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) { analyzeTermDefinition(term, original.getVocabulary()); } - if (!Objects.equals(original.getLabel(), term.getLabel())) { + if (!Objects.equals(original.getLabel(), term.getLabel()) && !config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) { vocabularyService.runTextAnalysisOnAllTerms(getVocabularyReference(original.getVocabulary())); } return result; diff --git a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java index 5f43aa236..1b7bcaf11 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java @@ -600,6 +600,8 @@ public static class TextAnalysis { @Min(8) private int textQuoteSelectorContextLength = 32; + private boolean disableVocabularyAnalysisOnTermEdit = false; + public String getUrl() { return url; } @@ -623,6 +625,14 @@ public int getTextQuoteSelectorContextLength() { public void setTextQuoteSelectorContextLength(int textQuoteSelectorContextLength) { this.textQuoteSelectorContextLength = textQuoteSelectorContextLength; } + + public boolean isDisableVocabularyAnalysisOnTermEdit() { + return disableVocabularyAnalysisOnTermEdit; + } + + public void setDisableVocabularyAnalysisOnTermEdit(boolean disableVocabularyAnalysisOnTermEdit) { + this.disableVocabularyAnalysisOnTermEdit = disableVocabularyAnalysisOnTermEdit; + } } @Validated From 2bb75b50234be75bf122277eba0d526d9e23a8f6 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Sat, 17 Aug 2024 14:44:59 +0200 Subject: [PATCH 039/150] [kbss-cvut/termit-ui#449] Set imported term state to configured initial if Excel does not specify it. --- .../excel/LocalizedSheetImporter.java | 12 ++++++-- .../importer/excel/ExcelImporterTest.java | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index e6c2c9568..293b8f2ba 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -208,8 +208,7 @@ private void mapRowToTermAttributes(Term term, Row termRow) { getAttributeValue(termRow, SKOS.EXACT_MATCH).ifPresent( exm -> mapSkosMatchProperties(term, SKOS.EXACT_MATCH, splitIntoMultipleValues(exm))); getAttributeValue(termRow, JsonLd.TYPE).flatMap(this::resolveTermType).ifPresent(t -> term.setTypes(Set.of(t))); - getAttributeValue(termRow, Vocabulary.s_p_ma_stav_pojmu).flatMap(this::resolveTermState) - .ifPresent(term::setState); + resolveTermState(getAttributeValue(termRow, Vocabulary.s_p_ma_stav_pojmu).orElse(null)).ifPresent(term::setState); } @@ -290,10 +289,17 @@ private Optional resolveTermType(String value) { } private Optional resolveTermState(String value) { - return languageService.getTermStates().stream() + if (value == null) { + return languageService.getInitialTermState().map(RdfsResource::getUri); + } + final Optional state = languageService.getTermStates().stream() .filter(t -> value.equals(t.getLabel().get(langTag)) || value.equals( t.getUri().toString())).findFirst() .map(RdfsResource::getUri); + if (state.isPresent()) { + return state; + } + return languageService.getInitialTermState().map(RdfsResource::getUri); } List getRawDataToInsert() { diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index fc9c3de05..8d9555ca7 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -539,4 +539,34 @@ void importResolvesTermStateAndTypesUsingLabels() { assertEquals(Set.of(type.getUri().toString()), result.getTypes()); assertEquals(state.getUri(), result.getState()); } + + @Test + void importSetsConfiguredInitialTermStateWhenSheetDoesNotSpecifyIt() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + final RdfsResource state = new RdfsResource( + URI.create("http://onto.fel.cvut.cz/ontologies/application/termit/pojem/navrhovaný-pojem"), + MultilingualString.create("Proposed term", Constants.DEFAULT_LANGUAGE), null, + "http://onto.fel.cvut.cz/ontologies/slovník/agendový/popis-dat/pojem/stav-pojmu"); + when(languageService.getInitialTermState()).thenReturn(Optional.of(state)); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); + + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertEquals(2, captor.getAllValues().size()); + final Optional building = captor.getAllValues().stream() + .filter(t -> "Building".equals(t.getLabel().get("en"))).findAny(); + assertTrue(building.isPresent()); + assertEquals(state.getUri(), building.get().getState()); + final Optional construction = captor.getAllValues().stream() + .filter(t -> "Construction".equals(t.getLabel().get("en"))).findAny(); + assertTrue(construction.isPresent()); + assertEquals(state.getUri(), construction.get().getState()); + } } From 7d7509e40537b95d9a13e1a585518543fb804b48 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Sat, 17 Aug 2024 15:07:41 +0200 Subject: [PATCH 040/150] [kbss-cvut/termit-ui#449] Modify Excel template download so that it (hopefully) works also in a Docker container. --- Dockerfile | 4 +-- .../service/business/VocabularyService.java | 20 ++++++------- .../util/TypeAwareByteArrayResource.java | 3 +- .../util/TypeAwareClasspathResource.java | 29 +++++++++++++++++++ .../kbss/termit/util/TypeAwareResource.java | 2 ++ 5 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/util/TypeAwareClasspathResource.java diff --git a/Dockerfile b/Dockerfile index 6902bfd34..54cb35925 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ # along with this program. If not, see . # -FROM maven:3-eclipse-temurin-17 as build +FROM maven:3-eclipse-temurin-17 AS build WORKDIR /termit @@ -31,7 +31,7 @@ COPY src src RUN mvn package -B -P graphdb,standalone -DskipTests=true -FROM eclipse-temurin:17-jdk-alpine as runtime +FROM eclipse-temurin:17-jdk-alpine AS runtime COPY --from=build /termit/target/termit.jar termit.jar EXPOSE 8080 diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 2100694ac..a35eb7814 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -26,7 +26,6 @@ import cz.cvut.kbss.termit.dto.listing.VocabularyDto; import cz.cvut.kbss.termit.event.VocabularyCreatedEvent; import cz.cvut.kbss.termit.exception.NotFoundException; -import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.acl.AccessControlList; import cz.cvut.kbss.termit.model.acl.AccessControlRecord; @@ -44,6 +43,7 @@ import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.util.TypeAwareClasspathResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -61,7 +61,6 @@ import java.io.File; import java.net.URI; -import java.net.URISyntaxException; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; @@ -259,15 +258,14 @@ public Vocabulary importVocabulary(URI vocabularyIri, MultipartFile file) { */ public TypeAwareResource getExcelTemplateFile() { final Configuration config = context.getBean(Configuration.class); - final File templateFile = config.getTemplate().getExcelImport().map(File::new).orElseGet(() -> { - try { - assert getClass().getClassLoader().getResource("template/termit-import.xlsx") != null; - return new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); - } catch (URISyntaxException e) { - throw new TermItException("Fatal error, unable to load Excel template file.", e); - } - }); - return new TypeAwareFileSystemResource(templateFile, ExportFormat.EXCEL.getMediaType()); + return config.getTemplate().getExcelImport().map(File::new) + .map(f -> (TypeAwareResource) new TypeAwareFileSystemResource(f, + ExportFormat.EXCEL.getMediaType())) + .orElseGet(() -> { + assert getClass().getClassLoader().getResource("template/termit-import.xlsx") != null; + return new TypeAwareClasspathResource("template/termit-import.xlsx", + ExportFormat.EXCEL.getMediaType()); + }); } @Override diff --git a/src/main/java/cz/cvut/kbss/termit/service/export/util/TypeAwareByteArrayResource.java b/src/main/java/cz/cvut/kbss/termit/service/export/util/TypeAwareByteArrayResource.java index 9559bd0e2..4fb087437 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/export/util/TypeAwareByteArrayResource.java +++ b/src/main/java/cz/cvut/kbss/termit/service/export/util/TypeAwareByteArrayResource.java @@ -52,13 +52,12 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (!(o instanceof TypeAwareByteArrayResource)) { + if (!(o instanceof TypeAwareByteArrayResource that)) { return false; } if (!super.equals(o)) { return false; } - TypeAwareByteArrayResource that = (TypeAwareByteArrayResource) o; return Objects.equals(mediaType, that.mediaType) && Objects.equals(fileExtension, that.fileExtension); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/TypeAwareClasspathResource.java b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareClasspathResource.java new file mode 100644 index 000000000..60d04c21e --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareClasspathResource.java @@ -0,0 +1,29 @@ +package cz.cvut.kbss.termit.util; + +import org.springframework.core.io.ClassPathResource; + +import java.util.Optional; + +/** + * Implementation of {@link TypeAwareResource} for files on classpath. + */ +public class TypeAwareClasspathResource extends ClassPathResource implements TypeAwareResource { + + private final String mediaType; + + public TypeAwareClasspathResource(String path, String mediaType) { + super(path); + this.mediaType = mediaType; + } + + @Override + public Optional getMediaType() { + return Optional.ofNullable(mediaType); + } + + @Override + public Optional getFileExtension() { + return getPath().contains(".") ? Optional.of(getPath().substring(getPath().lastIndexOf("."))) : + Optional.empty(); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java index 211b199e1..338ce1dae 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java +++ b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java @@ -26,6 +26,8 @@ *

* Allows to get MIME type of the resource and the associated file extension. However, both methods return * {@link Optional} to accommodate resources which may not support this feature. + * + * TODO Move all implementations into the same package! */ public interface TypeAwareResource extends Resource { From a267a2d241d1333052ebccc448d2b6fae7035020 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Sat, 17 Aug 2024 15:12:55 +0200 Subject: [PATCH 041/150] [Ref] Move all TypeAwareResource implementations into the util package. --- src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java | 2 +- .../cvut/kbss/termit/service/business/VocabularyService.java | 2 +- .../kbss/termit/service/document/DefaultDocumentManager.java | 2 +- .../document/html/UnconfirmedTermOccurrenceRemover.java | 2 +- .../kbss/termit/service/export/ExcelVocabularyExporter.java | 2 +- .../kbss/termit/service/export/SKOSVocabularyExporter.java | 3 +-- .../{service/export => }/util/TypeAwareByteArrayResource.java | 3 +-- .../document => }/util/TypeAwareFileSystemResource.java | 3 +-- src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java | 2 -- .../java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java | 2 +- src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java | 2 +- .../cz/cvut/kbss/termit/rest/VocabularyControllerTest.java | 2 +- .../cvut/kbss/termit/service/business/ResourceServiceTest.java | 2 +- .../cz/cvut/kbss/termit/service/business/TermServiceTest.java | 2 +- .../document/html/UnconfirmedTermOccurrenceRemoverTest.java | 2 +- 15 files changed, 14 insertions(+), 19 deletions(-) rename src/main/java/cz/cvut/kbss/termit/{service/export => }/util/TypeAwareByteArrayResource.java (95%) rename src/main/java/cz/cvut/kbss/termit/{service/document => }/util/TypeAwareFileSystemResource.java (95%) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java index b956d5478..c09d758dd 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java @@ -28,7 +28,7 @@ import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.persistence.dao.util.Quad; import cz.cvut.kbss.termit.service.export.ExportFormat; -import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Configuration.Persistence; import cz.cvut.kbss.termit.util.TypeAwareResource; diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index a35eb7814..ba7507daa 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -36,7 +36,7 @@ import cz.cvut.kbss.termit.persistence.snapshot.SnapshotCreator; import cz.cvut.kbss.termit.service.business.async.AsyncTermService; import cz.cvut.kbss.termit.service.changetracking.ChangeRecordProvider; -import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; +import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManager.java b/src/main/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManager.java index fa8bdc199..78125ef2f 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManager.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManager.java @@ -26,7 +26,7 @@ import cz.cvut.kbss.termit.model.resource.File; import cz.cvut.kbss.termit.model.resource.Resource; import cz.cvut.kbss.termit.service.IdentifierResolver; -import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; +import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.Utils; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/UnconfirmedTermOccurrenceRemover.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/UnconfirmedTermOccurrenceRemover.java index 0d3055ef3..60baf9437 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/UnconfirmedTermOccurrenceRemover.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/UnconfirmedTermOccurrenceRemover.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.service.document.html; import cz.cvut.kbss.termit.exception.FileContentProcessingException; -import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; diff --git a/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java b/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java index 1006abfb8..c15896df4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java @@ -24,7 +24,7 @@ import cz.cvut.kbss.termit.exception.UnsupportedOperationException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import cz.cvut.kbss.termit.util.Constants; diff --git a/src/main/java/cz/cvut/kbss/termit/service/export/SKOSVocabularyExporter.java b/src/main/java/cz/cvut/kbss/termit/service/export/SKOSVocabularyExporter.java index 2d03290f6..1d27e735d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/export/SKOSVocabularyExporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/export/SKOSVocabularyExporter.java @@ -20,11 +20,10 @@ import cz.cvut.kbss.termit.exception.UnsupportedOperationException; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.skos.SKOSExporter; -import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/cz/cvut/kbss/termit/service/export/util/TypeAwareByteArrayResource.java b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareByteArrayResource.java similarity index 95% rename from src/main/java/cz/cvut/kbss/termit/service/export/util/TypeAwareByteArrayResource.java rename to src/main/java/cz/cvut/kbss/termit/util/TypeAwareByteArrayResource.java index 4fb087437..775a75569 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/export/util/TypeAwareByteArrayResource.java +++ b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareByteArrayResource.java @@ -15,9 +15,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package cz.cvut.kbss.termit.service.export.util; +package cz.cvut.kbss.termit.util; -import cz.cvut.kbss.termit.util.TypeAwareResource; import org.springframework.core.io.ByteArrayResource; import java.util.Objects; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareFileSystemResource.java similarity index 95% rename from src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java rename to src/main/java/cz/cvut/kbss/termit/util/TypeAwareFileSystemResource.java index fa6898044..b497c4270 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/util/TypeAwareFileSystemResource.java +++ b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareFileSystemResource.java @@ -15,9 +15,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package cz.cvut.kbss.termit.service.document.util; +package cz.cvut.kbss.termit.util; -import cz.cvut.kbss.termit.util.TypeAwareResource; import org.springframework.core.io.FileSystemResource; import java.io.File; diff --git a/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java index 338ce1dae..211b199e1 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java +++ b/src/main/java/cz/cvut/kbss/termit/util/TypeAwareResource.java @@ -26,8 +26,6 @@ *

* Allows to get MIME type of the resource and the associated file extension. However, both methods return * {@link Optional} to accommodate resources which may not support this feature. - * - * TODO Move all implementations into the same package! */ public interface TypeAwareResource extends Resource { diff --git a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java index a9ef5cae9..a9d0e35d9 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java @@ -32,7 +32,7 @@ import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.ResourceService; import cz.cvut.kbss.termit.service.document.ResourceRetrievalSpecification; -import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; +import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java index 3133f713c..d3e74f5aa 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java @@ -41,7 +41,7 @@ import cz.cvut.kbss.termit.service.export.ExportConfig; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.export.ExportType; -import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index bad733dec..cccd38e09 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -38,7 +38,7 @@ import cz.cvut.kbss.termit.rest.handler.ErrorInfo; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.VocabularyService; -import cz.cvut.kbss.termit.service.document.util.TypeAwareFileSystemResource; +import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/ResourceServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/ResourceServiceTest.java index 91347cffe..068a86516 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/ResourceServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/ResourceServiceTest.java @@ -33,7 +33,7 @@ import cz.cvut.kbss.termit.service.document.DocumentManager; import cz.cvut.kbss.termit.service.document.ResourceRetrievalSpecification; import cz.cvut.kbss.termit.service.document.TextAnalysisService; -import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.ResourceRepositoryService; import cz.cvut.kbss.termit.util.TypeAwareResource; diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java index 48566799b..22a7898d7 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java @@ -38,7 +38,7 @@ import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.export.ExportType; import cz.cvut.kbss.termit.service.export.VocabularyExporters; -import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.service.language.LanguageService; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/html/UnconfirmedTermOccurrenceRemoverTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/html/UnconfirmedTermOccurrenceRemoverTest.java index 994228be5..111484a1e 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/html/UnconfirmedTermOccurrenceRemoverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/html/UnconfirmedTermOccurrenceRemoverTest.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.service.document.html; import cz.cvut.kbss.termit.environment.Environment; -import cz.cvut.kbss.termit.service.export.util.TypeAwareByteArrayResource; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; From 8b1a07da21fac8fd55f5efb5d2f61de99acf4f83 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 19 Aug 2024 18:29:59 +0200 Subject: [PATCH 042/150] [kbss-cvut/termit-ui#449] Throw exception when sheet contains duplicate term labels. --- .../excel/LocalizedSheetImporter.java | 15 +++++++--- .../importer/excel/ExcelImporterTest.java | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 293b8f2ba..ecc4d49c2 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -6,6 +6,7 @@ import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.jsonld.JsonLd; import cz.cvut.kbss.termit.dto.RdfsResource; +import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.export.util.TabularTermExportUtils; @@ -144,6 +145,11 @@ private void findTerms(Sheet sheet) { final Optional label = getAttributeValue(termRow, SKOS.PREF_LABEL); if (label.isPresent()) { initSingularMultilingualString(term::getLabel, term::setLabel).set(langTag, label.get()); + if (labelToTerm.containsKey(label.get())) { + throw new VocabularyImportException( + "Sheet " + sheet.getSheetName() + " contains multiple terms with the same label: " + label.get(), + "error.vocabulary.import.excel.duplicateLabel"); + } labelToTerm.put(label.get(), term); } else { if (i > existingTerms.size()) { @@ -208,7 +214,8 @@ private void mapRowToTermAttributes(Term term, Row termRow) { getAttributeValue(termRow, SKOS.EXACT_MATCH).ifPresent( exm -> mapSkosMatchProperties(term, SKOS.EXACT_MATCH, splitIntoMultipleValues(exm))); getAttributeValue(termRow, JsonLd.TYPE).flatMap(this::resolveTermType).ifPresent(t -> term.setTypes(Set.of(t))); - resolveTermState(getAttributeValue(termRow, Vocabulary.s_p_ma_stav_pojmu).orElse(null)).ifPresent(term::setState); + resolveTermState(getAttributeValue(termRow, Vocabulary.s_p_ma_stav_pojmu).orElse(null)).ifPresent( + term::setState); } @@ -293,9 +300,9 @@ private Optional resolveTermState(String value) { return languageService.getInitialTermState().map(RdfsResource::getUri); } final Optional state = languageService.getTermStates().stream() - .filter(t -> value.equals(t.getLabel().get(langTag)) || value.equals( - t.getUri().toString())).findFirst() - .map(RdfsResource::getUri); + .filter(t -> value.equals(t.getLabel().get(langTag)) || value.equals( + t.getUri().toString())).findFirst() + .map(RdfsResource::getUri); if (state.isPresent()) { return state; } diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 8d9555ca7..dca95ce42 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -8,6 +8,7 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.exception.importing.VocabularyDoesNotExistException; +import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.DataDao; @@ -19,6 +20,9 @@ import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +34,8 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.net.URI; import java.util.Collection; import java.util.List; @@ -45,6 +51,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -569,4 +576,25 @@ void importSetsConfiguredInitialTermStateWhenSheetDoesNotSpecifyIt() { assertTrue(construction.isPresent()); assertEquals(state.getUri(), construction.get().getState()); } + + @Test + void importThrowsVocabularyImportExceptionWhenSheetContainsDuplicateLabels() throws Exception { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + final Workbook input = new XSSFWorkbook(Environment.loadFile("template/termit-import.xlsx")); + final Sheet sheet = input.getSheet("English"); + sheet.getRow(1).getCell(0).setCellValue("Construction"); + sheet.getRow(2).getCell(0).setCellValue("Construction"); + final ByteArrayOutputStream bos = new ByteArrayOutputStream(); + input.write(bos); + + final VocabularyImportException ex = assertThrows(VocabularyImportException.class, + () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, new ByteArrayInputStream(bos.toByteArray())))); + assertEquals("error.vocabulary.import.excel.duplicateLabel", ex.getMessageId()); + verify(termService, never()).addRootTermToVocabulary(any(), eq(vocabulary)); + } + + // TODO } From 01c8be45b17364043a92094273093367c0c089cf Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 26 Aug 2024 08:35:51 +0200 Subject: [PATCH 043/150] [kbss-cvut/termit-ui#449] Throw exception when sheet contains duplicate term identifiers. --- .../excel/LocalizedSheetImporter.java | 8 +++++++ .../importer/excel/ExcelImporterTest.java | 24 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index ecc4d49c2..30822660f 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -64,6 +64,8 @@ class LocalizedSheetImporter { private final Map labelToTerm = new LinkedHashMap<>(); private final Map idToTerm = new HashMap<>(); + // Identifiers discovered in this sheet + private final Set sheetIdentifiers = new HashSet<>(); private List rawDataToInsert; LocalizedSheetImporter(Services services, PrefixMap prefixMap, List existingTerms, @@ -140,7 +142,13 @@ private void findTerms(Sheet sheet) { Term term = existingTerms.size() >= i ? existingTerms.get(i - 1) : new Term(); getAttributeValue(termRow, JsonLd.ID).ifPresent(id -> { term.setUri(resolveTermUri(id)); + if (sheetIdentifiers.contains(term.getUri())) { + throw new VocabularyImportException( + "Sheet " + sheet.getSheetName() + " contains multiple terms with the same identifier: " + id, + "error.vocabulary.import.excel.duplicateIdentifier"); + } idToTerm.put(term.getUri(), term); + sheetIdentifiers.add(term.getUri()); }); final Optional label = getAttributeValue(termRow, SKOS.PREF_LABEL); if (label.isPresent()) { diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index dca95ce42..81a4adc35 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -596,5 +596,27 @@ void importThrowsVocabularyImportExceptionWhenSheetContainsDuplicateLabels() thr verify(termService, never()).addRootTermToVocabulary(any(), eq(vocabulary)); } - // TODO + @Test + void importThrowsVocabularyImportExceptionWhenSheetContainsDuplicateIdentifiers() throws Exception { + vocabulary.setUri(URI.create("http://example.com")); + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + final Workbook input = new XSSFWorkbook(Environment.loadFile("template/termit-import.xlsx")); + final Sheet sheet = input.getSheet("English"); + sheet.shiftColumns(0, 12, 1); + sheet.getRow(0).createCell(0).setCellValue("Identifier"); + sheet.getRow(1).createCell(0).setCellValue("http://example.com/terms/Construction"); + sheet.getRow(1).getCell(1).setCellValue("Construction"); + sheet.getRow(2).createCell(0).setCellValue("http://example.com/terms/Construction"); + sheet.getRow(2).getCell(1).setCellValue("Another Construction"); + final ByteArrayOutputStream bos = new ByteArrayOutputStream(); + input.write(bos); + + final VocabularyImportException ex = assertThrows(VocabularyImportException.class, + () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, new ByteArrayInputStream(bos.toByteArray())))); + assertEquals("error.vocabulary.import.excel.duplicateIdentifier", ex.getMessageId()); + verify(termService, never()).addRootTermToVocabulary(any(), eq(vocabulary)); + } } From 908762120df0208bf7d93ba23a8c8986c31132fd Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 29 Aug 2024 13:59:25 +0200 Subject: [PATCH 044/150] [kbss-cvut/termit-ui#449] Use first state and type encountered for a term. --- .../excel/LocalizedSheetImporter.java | 16 +++-- .../resources/template/termit-import.xlsx | Bin 55991 -> 56181 bytes .../importer/excel/ExcelImporterTest.java | 59 ++++++++++++++++-- .../data/import-with-type-state-en.xlsx | Bin 35558 -> 32321 bytes 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 30822660f..325df13a6 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -221,8 +221,8 @@ private void mapRowToTermAttributes(Term term, Row termRow) { rtm -> mapSkosMatchProperties(term, SKOS.RELATED_MATCH, splitIntoMultipleValues(rtm))); getAttributeValue(termRow, SKOS.EXACT_MATCH).ifPresent( exm -> mapSkosMatchProperties(term, SKOS.EXACT_MATCH, splitIntoMultipleValues(exm))); - getAttributeValue(termRow, JsonLd.TYPE).flatMap(this::resolveTermType).ifPresent(t -> term.setTypes(Set.of(t))); - resolveTermState(getAttributeValue(termRow, Vocabulary.s_p_ma_stav_pojmu).orElse(null)).ifPresent( + getAttributeValue(termRow, JsonLd.TYPE).flatMap(t -> resolveTermType(t, term)).ifPresent(t -> term.setTypes(Set.of(t))); + resolveTermState(getAttributeValue(termRow, Vocabulary.s_p_ma_stav_pojmu).orElse(null), term).ifPresent( term::setState); } @@ -296,14 +296,22 @@ private void mapSkosMatchProperties(Term subject, String property, Set o new ExcelImporter.TermRelationship(subject, propertyUri, new Term(uri)))); } - private Optional resolveTermType(String value) { + private Optional resolveTermType(String value, Term term) { + if (!Utils.emptyIfNull(term.getTypes()).isEmpty()) { + // Type already present from previous sheet + return Optional.empty(); + } return languageService.getTermTypes().stream() .filter(t -> value.equals(t.getLabel().get(langTag)) || value.equals( t.getUri().toString())).findFirst() .map(t -> t.getUri().toString()); } - private Optional resolveTermState(String value) { + private Optional resolveTermState(String value, Term term) { + if (term.getState() != null) { + // State already present from previous sheet + return Optional.empty(); + } if (value == null) { return languageService.getInitialTermState().map(RdfsResource::getUri); } diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx index 942658ca0edb7f92339d07333df2ede054cfde65..729fd2cb4b9d8df26528eac797c0cd3457196183 100644 GIT binary patch delta 15590 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 diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 81a4adc35..74bb3d7f5 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -44,6 +44,8 @@ import java.util.Set; import java.util.function.Consumer; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -589,9 +591,14 @@ void importThrowsVocabularyImportExceptionWhenSheetContainsDuplicateLabels() thr input.write(bos); final VocabularyImportException ex = assertThrows(VocabularyImportException.class, - () -> sut.importVocabulary( - new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), - new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, new ByteArrayInputStream(bos.toByteArray())))); + () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, + vocabulary.getUri(), + prePersist), + new VocabularyImporter.ImportInput( + Constants.MediaType.EXCEL, + new ByteArrayInputStream( + bos.toByteArray())))); assertEquals("error.vocabulary.import.excel.duplicateLabel", ex.getMessageId()); verify(termService, never()).addRootTermToVocabulary(any(), eq(vocabulary)); } @@ -614,9 +621,51 @@ void importThrowsVocabularyImportExceptionWhenSheetContainsDuplicateIdentifiers( final VocabularyImportException ex = assertThrows(VocabularyImportException.class, () -> sut.importVocabulary( - new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), - new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, new ByteArrayInputStream(bos.toByteArray())))); + new VocabularyImporter.ImportConfiguration(false, + vocabulary.getUri(), + prePersist), + new VocabularyImporter.ImportInput( + Constants.MediaType.EXCEL, + new ByteArrayInputStream( + bos.toByteArray())))); assertEquals("error.vocabulary.import.excel.duplicateIdentifier", ex.getMessageId()); verify(termService, never()).addRootTermToVocabulary(any(), eq(vocabulary)); } + + @Test + void importSupportsSpecifyingStateAndTypeOnlyInOneSheet() throws Exception { + vocabulary.setUri(URI.create("http://example.com")); + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + final Workbook input = new XSSFWorkbook(Environment.loadFile("template/termit-import.xlsx")); + final Sheet englishSheet = input.getSheet("English"); + englishSheet.getRow(1).createCell(0).setCellValue("Construction"); + final Sheet czechSheet = input.getSheet("Czech"); + czechSheet.getRow(1).createCell(0).setCellValue("Konstrukce"); + czechSheet.getRow(1).createCell(9).setCellValue("Publikovaný pojem"); + czechSheet.getRow(1).createCell(5).setCellValue("Typ objektu"); + final Term type = Generator.generateTermWithId(); + type.setUri(URI.create("http://onto.fel.cvut.cz/ontologies/ufo/object-type")); + type.setLabel(MultilingualString.create("Object Type", Constants.DEFAULT_LANGUAGE)); + type.getLabel().set("cs", "Typ objektu"); + when(languageService.getTermTypes()).thenReturn(List.of(type)); + final RdfsResource state = new RdfsResource( + URI.create("http://onto.fel.cvut.cz/ontologies/application/termit/pojem/publikovaný-pojem"), + MultilingualString.create("Published term", Constants.DEFAULT_LANGUAGE), null, + "http://onto.fel.cvut.cz/ontologies/slovník/agendový/popis-dat/pojem/stav-pojmu"); + state.getLabel().set("cs", "Publikovaný pojem"); + when(languageService.getTermStates()).thenReturn(List.of(state)); + final ByteArrayOutputStream bos = new ByteArrayOutputStream(); + input.write(bos); + + sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + new ByteArrayInputStream(bos.toByteArray()))); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertThat(captor.getValue().getTypes(), hasItem(type.getUri().toString())); + assertEquals(state.getUri(), captor.getValue().getState()); + verify(languageService, never()).getInitialTermState(); + } } diff --git a/src/test/resources/data/import-with-type-state-en.xlsx b/src/test/resources/data/import-with-type-state-en.xlsx index 517481b3a64e1a22da40391142ad6a833c382a2b..9f70f093a2018a6c8e03f97676317d974920478f 100644 GIT binary patch literal 32321 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 K%`J53U;hJS4%WK> literal 35558 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 From e65e25652fef3f462d6474eb7371ae01640a52af Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 29 Aug 2024 14:45:40 +0200 Subject: [PATCH 045/150] [kbss-cvut/termit-ui#449] Resolve imported term identifier before persisting it so that existing one can be removed. If the template is used, it does not contain identifier column, so the id would be generated on persist. On repeated import, this leads to errors because the existing term is not removed before the new one is persisted. --- .../service/importer/excel/ExcelImporter.java | 36 ++++++++++++---- .../excel/LocalizedSheetImporter.java | 42 +++---------------- .../repository/TermRepositoryService.java | 4 +- .../importer/excel/ExcelImporterTest.java | 39 ----------------- 4 files changed, 34 insertions(+), 87 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index 75d299c5e..a23228b71 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -99,7 +99,6 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) } final Vocabulary targetVocabulary = vocabularyDao.find(config.vocabularyIri()).orElseThrow( () -> NotFoundException.create(Vocabulary.class, config.vocabularyIri())); - final String termNamespace = resolveVocabularyTermNamespace(targetVocabulary); try { List terms = Collections.emptyList(); Set rawDataToInsert = new HashSet<>(); @@ -114,17 +113,18 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) continue; } final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter( - new LocalizedSheetImporter.Services(termService, languageService, idResolver), - prefixMap, terms, termNamespace); + new LocalizedSheetImporter.Services(termService, languageService), + prefixMap, terms); terms = sheetImporter.resolveTermsFromSheet(sheet); rawDataToInsert.addAll(sheetImporter.getRawDataToInsert()); } - terms.stream().filter(t -> t.getUri() != null && termService.exists(t.getUri())).forEach(t -> { - LOG.trace("Term {} already exists. Removing old version.", t); - termService.forceRemove(termService.findRequired(t.getUri())); - // Flush changes to prevent EntityExistsExceptions when term is already managed in PC as different type (Term vs TermInfo) - em.flush(); - }); + terms.stream().peek(t -> t.setUri(resolveTermIdentifier(targetVocabulary, t))) + .filter(t -> termService.exists(t.getUri())).forEach(t -> { + LOG.trace("Term {} already exists. Removing old version.", t); + termService.forceRemove(termService.findRequired(t.getUri())); + // Flush changes to prevent EntityExistsExceptions when term is already managed in PC as different type (Term vs TermInfo) + em.flush(); + }); // Ensure all parents are saved before we start adding children terms.stream().filter(t -> Utils.emptyIfNull(t.getParentTerms()).isEmpty()) .forEach(root -> { @@ -173,6 +173,24 @@ private String resolveVocabularyTermNamespace(Vocabulary vocabulary) { config.getNamespace().getTerm().getSeparator()); } + private URI resolveTermIdentifier(Vocabulary vocabulary, Term term) { + final String termNamespace = resolveVocabularyTermNamespace(vocabulary); + if (term.getUri() == null) { + return idResolver.generateDerivedIdentifier(vocabulary.getUri(), + config.getNamespace().getTerm().getSeparator(), + term.getLabel().get(config.getPersistence().getLanguage())); + } + if (term.getUri() != null && !term.getUri().toString().startsWith(termNamespace)) { + LOG.trace( + "Existing term identifier {} does not correspond to the expected vocabulary term namespace {}. Adjusting the term id.", + Utils.uriToString(term.getUri()), termNamespace); + return idResolver.generateDerivedIdentifier(vocabulary.getUri(), + config.getNamespace().getTerm().getSeparator(), + term.getLabel().get(config.getPersistence().getLanguage())); + } + return term.getUri(); + } + /** * Checks whether this importer supports the specified media type. * diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 325df13a6..6037978f4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -8,7 +8,6 @@ import cz.cvut.kbss.termit.dto.RdfsResource; import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; import cz.cvut.kbss.termit.model.Term; -import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.export.util.TabularTermExportUtils; import cz.cvut.kbss.termit.service.language.LanguageService; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; @@ -55,9 +54,6 @@ class LocalizedSheetImporter { private final LanguageService languageService; private final PrefixMap prefixMap; private final List existingTerms; - private final IdentifierResolver idResolver; - // Namespace expected based on the vocabulary into which the terms will be imported - private final String expectedTermNamespace; private Map attributeToColumn; private String langTag; @@ -68,14 +64,11 @@ class LocalizedSheetImporter { private final Set sheetIdentifiers = new HashSet<>(); private List rawDataToInsert; - LocalizedSheetImporter(Services services, PrefixMap prefixMap, List existingTerms, - String expectedTermNamespace) { + LocalizedSheetImporter(Services services, PrefixMap prefixMap, List existingTerms) { this.termRepositoryService = services.termRepositoryService(); this.languageService = services.languageService(); this.prefixMap = prefixMap; this.existingTerms = existingTerms; - this.idResolver = services.idResolver(); - this.expectedTermNamespace = expectedTermNamespace; existingTerms.stream().filter(t -> t.getUri() != null).forEach(t -> idToTerm.put(t.getUri(), t)); } @@ -141,7 +134,7 @@ private void findTerms(Sheet sheet) { final Row termRow = sheet.getRow(i); Term term = existingTerms.size() >= i ? existingTerms.get(i - 1) : new Term(); getAttributeValue(termRow, JsonLd.ID).ifPresent(id -> { - term.setUri(resolveTermUri(id)); + term.setUri(URI.create(prefixMap.resolvePrefixed(id))); if (sheetIdentifiers.contains(term.getUri())) { throw new VocabularyImportException( "Sheet " + sheet.getSheetName() + " contains multiple terms with the same identifier: " + id, @@ -171,31 +164,6 @@ private void findTerms(Sheet sheet) { LOG.trace("Found {} term rows.", i - 1); } - /** - * If an identifier column is found in the sheet, attempt to use it for term ids. - *

- * This methods first resolves possible prefixes and then ensures the identifier matches the expected namespace - * provided by the vocabulary into which we are importing. If it does not match, a new identifier is generated based - * on the expected namespace and the local name extracted from the identifier present in the sheet. - * - * @param id Identifier extracted from the sheet - * @return Valid term identifier matching target vocabulary - */ - private URI resolveTermUri(String id) { - // Handle prefix if it is used - id = prefixMap.resolvePrefixed(id); - final URI uriId = URI.create(id); - if (expectedTermNamespace.equals(IdentifierResolver.extractIdentifierNamespace(uriId))) { - return URI.create(id); - } else { - LOG.trace( - "Existing term identifier {} does not correspond to the expected vocabulary term namespace {}. Adjusting the term id.", - Utils.uriToString(uriId), expectedTermNamespace); - final String localName = IdentifierResolver.extractIdentifierFragment(uriId); - return idResolver.generateIdentifier(expectedTermNamespace, localName); - } - } - private void mapRowToTermAttributes(Term term, Row termRow) { getAttributeValue(termRow, SKOS.DEFINITION).ifPresent( d -> initSingularMultilingualString(term::getDefinition, term::setDefinition).set(langTag, d)); @@ -221,7 +189,8 @@ private void mapRowToTermAttributes(Term term, Row termRow) { rtm -> mapSkosMatchProperties(term, SKOS.RELATED_MATCH, splitIntoMultipleValues(rtm))); getAttributeValue(termRow, SKOS.EXACT_MATCH).ifPresent( exm -> mapSkosMatchProperties(term, SKOS.EXACT_MATCH, splitIntoMultipleValues(exm))); - getAttributeValue(termRow, JsonLd.TYPE).flatMap(t -> resolveTermType(t, term)).ifPresent(t -> term.setTypes(Set.of(t))); + getAttributeValue(termRow, JsonLd.TYPE).flatMap(t -> resolveTermType(t, term)) + .ifPresent(t -> term.setTypes(Set.of(t))); resolveTermState(getAttributeValue(termRow, Vocabulary.s_p_ma_stav_pojmu).orElse(null), term).ifPresent( term::setState); @@ -373,7 +342,6 @@ private static Set splitIntoMultipleValues(String value) { .collect(Collectors.toSet()); } - record Services(TermRepositoryService termRepositoryService, LanguageService languageService, - IdentifierResolver idResolver) { + record Services(TermRepositoryService termRepositoryService, LanguageService languageService) { } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java index ab1022aae..8acf9a004 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java @@ -163,9 +163,9 @@ private void prepareTermForPersist(Term instance, URI vocabularyUri) { pruneEmptyTranslations(instance); } - private URI generateIdentifier(URI vocabularyUri, MultilingualString multilingualString) { + private URI generateIdentifier(URI vocabularyUri, MultilingualString termLabel) { return idResolver.generateDerivedIdentifier(vocabularyUri, config.getNamespace().getTerm().getSeparator(), - multilingualString.get(config.getPersistence().getLanguage())); + termLabel.get(config.getPersistence().getLanguage())); } private void addTermAsRootToGlossary(Term instance, URI vocabularyIri) { diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 74bb3d7f5..6457a0f62 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -52,7 +52,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -133,7 +132,6 @@ void importThrowsVocabularyDoesNotExistExceptionWhenVocabularyIsNotFound() { void importCreatesRootTermsWithBasicAttributesFromEnglishSheet() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -155,34 +153,10 @@ void importCreatesRootTermsWithBasicAttributesFromEnglishSheet() { assertEquals("The process of building a building", construction.get().getDefinition().get("en")); } - private void initIdentifierGenerator(String lang, boolean forChild) { - doAnswer(inv -> { - final Term t = inv.getArgument(0); - if (t.getUri() != null) { - return null; - } - t.setUri(URI.create(vocabulary.getUri().toString() + "/" + IdentifierResolver.normalize( - t.getLabel().get(lang)))); - return null; - }).when(termService).addRootTermToVocabulary(any(Term.class), eq(vocabulary)); - if (forChild) { - doAnswer(inv -> { - final Term t = inv.getArgument(0); - if (t.getUri() != null) { - return null; - } - t.setUri(URI.create(vocabulary.getUri().toString() + "/" + IdentifierResolver.normalize( - t.getLabel().get(lang)))); - return null; - }).when(termService).addChildTerm(any(Term.class), any(Term.class)); - } - } - @Test void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -217,7 +191,6 @@ void importCreatesRootTermsWithPluralBasicAttributesFromEnglishSheet() { void importCreatesRootTermsWithBasicAttributesFromMultipleTranslationSheets() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -248,7 +221,6 @@ void importCreatesRootTermsWithBasicAttributesFromMultipleTranslationSheets() { void importCreatesRootTermsWithPluralBasicAttributesFromMultipleTranslationSheets() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -285,7 +257,6 @@ void importCreatesRootTermsWithPluralBasicAttributesFromMultipleTranslationSheet void importCreatesTermHierarchy() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, true); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -309,7 +280,6 @@ void importCreatesTermHierarchy() { void importSavesRelationshipsBetweenTerms() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -331,7 +301,6 @@ void importSavesRelationshipsBetweenTerms() { void importImportsTermsWhenAnotherLanguageSheetIsEmpty() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -357,7 +326,6 @@ void importImportsTermsWhenAnotherLanguageSheetIsEmpty() { void importFallsBackToEnglishColumnLabelsForUnknownLanguages() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator("de", false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -383,7 +351,6 @@ void importSupportsTermIdentifiers() { vocabulary.setUri(URI.create("http://example.com")); when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -414,7 +381,6 @@ void importSupportsPrefixedTermIdentifiers() { vocabulary.setUri(URI.create("http://example.com")); when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -444,7 +410,6 @@ void importSupportsPrefixedTermIdentifiers() { void importAdjustsTermIdentifiersToUseExistingVocabularyIdentifierAndSeparatorAsNamespace() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), @@ -467,7 +432,6 @@ void importRemovesExistingInstanceWhenImportedTermAlreadyExists() { vocabulary.setUri(URI.create("http://example.com")); when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Term existingBuilding = Generator.generateTermWithId(); existingBuilding.setUri(URI.create("http://example.com/terms/building")); final Term existingConstruction = Generator.generateTermWithId(); @@ -494,7 +458,6 @@ void importSupportsReferencesToOtherVocabulariesViaTermIdentifiersWhenReferenced vocabulary.setUri(URI.create("http://example.com")); when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); when(termService.exists(any())).thenReturn(false); when(termService.exists(URI.create("http://example.com/another-vocabulary/terms/relatedMatch"))).thenReturn( true); @@ -524,7 +487,6 @@ void importSupportsReferencesToOtherVocabulariesViaTermIdentifiersWhenReferenced void importResolvesTermStateAndTypesUsingLabels() { when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Term type = Generator.generateTermWithId(); type.setUri(URI.create("http://onto.fel.cvut.cz/ontologies/ufo/object-type")); type.setLabel(MultilingualString.create("Object Type", Constants.DEFAULT_LANGUAGE)); @@ -558,7 +520,6 @@ void importSetsConfiguredInitialTermStateWhenSheetDoesNotSpecifyIt() { MultilingualString.create("Proposed term", Constants.DEFAULT_LANGUAGE), null, "http://onto.fel.cvut.cz/ontologies/slovník/agendový/popis-dat/pojem/stav-pojmu"); when(languageService.getInitialTermState()).thenReturn(Optional.of(state)); - initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); final Vocabulary result = sut.importVocabulary( new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), From dd376ba9843bd972c1dbc19f2b4969e99ef02856 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 29 Aug 2024 15:41:02 +0200 Subject: [PATCH 046/150] [kbss-cvut/termit-ui#449] Prevent import of term with existing label and different identifier. Existing identifier would be deleted and re-inserted again, but a different identifier would lead to two terms with the same label (and different identifiers). --- .../kbss/termit/persistence/dao/TermDao.java | 74 ++++++++++++++----- .../service/importer/excel/ExcelImporter.java | 10 +++ .../repository/TermRepositoryService.java | 19 ++++- .../termit/persistence/dao/TermDaoTest.java | 29 ++++++-- .../importer/excel/ExcelImporterTest.java | 18 +++++ 5 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java index 95d458480..34ded7f67 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java @@ -17,6 +17,7 @@ */ package cz.cvut.kbss.termit.persistence.dao; +import cz.cvut.kbss.jopa.exceptions.NoResultException; import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.jopa.model.query.TypedQuery; import cz.cvut.kbss.jopa.vocabulary.SKOS; @@ -235,15 +236,15 @@ public void setState(Term term, URI state) { eventPublisher.publishEvent(new AssetUpdateEvent(this, term)); evictPossiblyCachedReferences(term); em.createNativeQuery("DELETE {" + - "?t ?hasState ?oldState ." + - "} INSERT {" + - "GRAPH ?g {" + - "?t ?hasState ?newState ." + - "}} WHERE {" + - "OPTIONAL {?t ?hasState ?oldState .}" + - "GRAPH ?g {" + - "?t ?inScheme ?glossary ." + - "}}").setParameter("t", term) + "?t ?hasState ?oldState ." + + "} INSERT {" + + "GRAPH ?g {" + + "?t ?hasState ?newState ." + + "}} WHERE {" + + "OPTIONAL {?t ?hasState ?oldState .}" + + "GRAPH ?g {" + + "?t ?inScheme ?glossary ." + + "}}").setParameter("t", term) .setParameter("hasState", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_p_ma_stav_pojmu)) .setParameter("inScheme", URI.create(SKOS.IN_SCHEME)) .setParameter("newState", state).executeUpdate(); @@ -403,14 +404,15 @@ public List findAllRoots(Vocabulary vocabulary, Pageable pageSpec, Coll Objects.requireNonNull(vocabulary); Objects.requireNonNull(pageSpec); TypedQuery query = em.createNativeQuery("SELECT DISTINCT ?term WHERE {" + - "SELECT DISTINCT ?term ?hasLocaleLabel WHERE {" + + "SELECT DISTINCT ?term ?hasLocaleLabel WHERE {" + "GRAPH ?context { " + "?term a ?type ;" + "?hasLabel ?label ." + "?vocabulary ?hasGlossary/?hasTerm ?term ." + "BIND((lang(?label) = ?labelLang) as ?hasLocaleLabel) ." + "FILTER (?term NOT IN (?included))" + - "}} ORDER BY DESC(?hasLocaleLabel) lang(?label) " + orderSentence("?label") + "}", + "}} ORDER BY DESC(?hasLocaleLabel) lang(?label) " + orderSentence( + "?label") + "}", TermDto.class); query = setCommonFindAllRootsQueryParams(query, false); try { @@ -467,14 +469,15 @@ private static String r(String string, String from, String to) { public List findAllRoots(Pageable pageSpec, Collection includeTerms) { Objects.requireNonNull(pageSpec); TypedQuery query = em.createNativeQuery("SELECT DISTINCT ?term WHERE {" + - "SELECT DISTINCT ?term ?hasLocaleLabel WHERE {" + + "SELECT DISTINCT ?term ?hasLocaleLabel WHERE {" + "?term a ?type ; " + "?hasLabel ?label . " + "?vocabulary ?hasGlossary/?hasTerm ?term . " + "BIND((lang(?label) = ?labelLang) as ?hasLocaleLabel) ." + "FILTER (?term NOT IN (?included)) . " + "FILTER NOT EXISTS {?term a ?snapshot .} " + - "} ORDER BY DESC(?hasLocaleLabel) lang(?label) " + orderSentence("?label") + "}", + "} ORDER BY DESC(?hasLocaleLabel) lang(?label) " + orderSentence( + "?label") + "}", TermDto.class); query = setCommonFindAllRootsQueryParams(query, false); try { @@ -552,14 +555,15 @@ public List findAllRootsIncludingImports(Vocabulary vocabulary, Pageabl Objects.requireNonNull(vocabulary); Objects.requireNonNull(pageSpec); TypedQuery query = em.createNativeQuery("SELECT DISTINCT ?term WHERE {" + - "SELECT DISTINCT ?term ?hasLocaleLabel WHERE {" + + "SELECT DISTINCT ?term ?hasLocaleLabel WHERE {" + "?term a ?type ;" + "?hasLabel ?label ." + "?vocabulary ?imports* ?parent ." + "?parent ?hasGlossary/?hasTerm ?term ." + "BIND((lang(?label) = ?labelLang) as ?hasLocaleLabel) ." + "FILTER (?term NOT IN (?included))" + - "} ORDER BY DESC(?hasLocaleLabel) lang(?label) " + orderSentence("?label") + "}", + "} ORDER BY DESC(?hasLocaleLabel) lang(?label) " + orderSentence( + "?label") + "}", TermDto.class); query = setCommonFindAllRootsQueryParams(query, true); try { @@ -697,8 +701,9 @@ public List findAllIncludingImported(String searchString, Vocabulary vo * Note that this method uses comparison ignoring case, so that two labels differing just in character case are * considered same here. * - * @param label Label to check - * @param vocabulary Vocabulary in which terms will be searched + * @param label Label to check + * @param vocabulary Vocabulary in which terms will be searched + * @param languageTag Language tag of the label, optional. If {@code null}, any language is accepted * @return Whether term with {@code label} already exists in vocabulary */ public boolean existsInVocabulary(String label, Vocabulary vocabulary, String languageTag) { @@ -722,6 +727,41 @@ public boolean existsInVocabulary(String label, Vocabulary vocabulary, String la } } + /** + * Gets the identifier of a term with the specified label in a vocabulary with the specified URI. + *

+ * Note that this method uses comparison ignoring case, so that two labels differing just in character case are + * considered same here. + * + * @param label Label to search by + * @param vocabulary Vocabulary in which terms will be searched + * @param languageTag Language tag of the label + * @return Identifier of matching term wrapped in an {@code Optional}, empty {@code Optional} if there is no such + * term + */ + public Optional findIdentifierByLabel(String label, Vocabulary vocabulary, String languageTag) { + Objects.requireNonNull(label); + Objects.requireNonNull(vocabulary); + try { + return Optional.of(em.createNativeQuery("SELECT ?term { ?term a ?type ; " + + "?hasLabel ?label ;" + + "?inVocabulary ?vocabulary ." + + "FILTER (LCASE(?label) = LCASE(?searchString)) . " + + "}", URI.class) + .setParameter("type", typeUri) + .setParameter("hasLabel", LABEL_PROP) + .setParameter("inVocabulary", + URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_p_je_pojmem_ze_slovniku)) + .setParameter("vocabulary", vocabulary.getUri()) + .setParameter("searchString", label, + languageTag != null ? languageTag : config.getLanguage()).getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } catch (RuntimeException e) { + throw new PersistenceException(e); + } + } + /** * Gets identifiers of all terms in the specified vocabulary that have no occurrences (file or definitional). * diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index a23228b71..a93e3cd0c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -34,6 +34,7 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; /** @@ -119,6 +120,15 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) rawDataToInsert.addAll(sheetImporter.getRawDataToInsert()); } terms.stream().peek(t -> t.setUri(resolveTermIdentifier(targetVocabulary, t))) + .peek(t -> t.getLabel().getValue().forEach((lang, value) -> { + final Optional existingUri = termService.findIdentifierByLabel(value, + targetVocabulary, + lang); + if (existingUri.isPresent() && !existingUri.get().equals(t.getUri())) { + throw new VocabularyImportException( + "Vocabulary already contains a term with label '" + value + "' with a different identifier than the imported one."); + } + })) .filter(t -> termService.exists(t.getUri())).forEach(t -> { LOG.trace("Term {} already exists. Removing old version.", t); termService.forceRemove(termService.findRequired(t.getUri())); diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java index 8acf9a004..24465e032 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java @@ -333,7 +333,7 @@ public List findAllIncludingImported(String searchString, Vocabulary vo * * @param label Label to check * @param vocabulary Vocabulary in which terms will be searched - * @param language Language to check the existence in + * @param language Language to check the existence in, optional. If not specified, any language is accepted * @return Whether term with {@code label} already exists in vocabulary */ @Transactional(readOnly = true) @@ -341,6 +341,23 @@ public boolean existsInVocabulary(String label, Vocabulary vocabulary, String la return termDao.existsInVocabulary(label, vocabulary, language); } + /** + * Gets the identifier of a term with the specified label in a vocabulary with the specified URI. + *

+ * Note that this method uses comparison ignoring case, so that two labels differing just in character case are + * considered same here. + * + * @param label Label to search by + * @param vocabulary Vocabulary in which terms will be searched + * @param language Language tag of the label, optional. If not specified, any language is accepted + * @return Identifier of matching term wrapped in an {@code Optional}, empty {@code Optional} if there is no such + * term + */ + @Transactional(readOnly = true) + public Optional findIdentifierByLabel(String label, Vocabulary vocabulary, String language) { + return termDao.findIdentifierByLabel(label, vocabulary, language); + } + /** * Retrieves aggregated information about the specified Term's occurrences in Resources and other Terms * definitions. diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java index b908ec514..506ad4d67 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java @@ -430,8 +430,9 @@ void updatePublishesVocabularyContentModifiedEvent() { transactional(() -> sut.update(term)); final ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); verify(eventPublisher, atLeastOnce()).publishEvent(captor.capture()); - final Optional evt = captor.getAllValues().stream().filter(VocabularyContentModified.class::isInstance) - .map(VocabularyContentModified.class::cast).findFirst(); + final Optional evt = captor.getAllValues().stream() + .filter(VocabularyContentModified.class::isInstance) + .map(VocabularyContentModified.class::cast).findFirst(); assertTrue(evt.isPresent()); assertEquals(vocabulary.getUri(), evt.get().getVocabularyIri()); } @@ -751,9 +752,9 @@ void findAllRootsReturnsTermsThatAreMissingDefaultLanguageLabel() { final List result = sut.findAllRoots(vocabulary, Constants.DEFAULT_PAGE_SPEC, Collections.emptyList()); assertEquals(4, result.size()); assertEquals(Arrays - .asList("China", "Germany", "Spain", "Syria"), - result.stream().map(r -> r.getLabel().get("en")) - .toList()); + .asList("China", "Germany", "Spain", "Syria"), + result.stream().map(r -> r.getLabel().get("en")) + .toList()); } /** @@ -1353,4 +1354,22 @@ void setStatePublishesAssetUpdateEvent() { assertTrue(evt.isPresent()); assertEquals(term, evt.get().getAsset()); } + + @Test + void findIdentifierByLabelReturnsIdentifierOfMatchingTerm() { + final List terms = generateTerms(2); + addTermsAndSave(new HashSet<>(terms), vocabulary); + + final Term term = terms.get(0); + final String label = term.getLabel().get(Environment.LANGUAGE); + final Optional result = sut.findIdentifierByLabel(label, vocabulary, Environment.LANGUAGE); + assertTrue(result.isPresent()); + assertEquals(term.getUri(), result.get()); + } + + @Test + void findIdentifierByLabelReturnsEmptyOptionalIfNoTermIsFound() { + final Optional result = sut.findIdentifierByLabel("foo", vocabulary, Environment.LANGUAGE); + assertFalse(result.isPresent()); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 6457a0f62..58c6fb842 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -52,6 +52,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -629,4 +630,21 @@ void importSupportsSpecifyingStateAndTypeOnlyInOneSheet() throws Exception { assertEquals(state.getUri(), captor.getValue().getState()); verify(languageService, never()).getInitialTermState(); } + + @Test + void importThrowsVocabularyImportExceptionWhenVocabularyAlreadyContainsTermWithSameLabelAndDifferentIdentifier() { + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + when(termService.findIdentifierByLabel(any(), any(), any())).thenReturn(Optional.empty()); + doReturn(Optional.of(URI.create( + vocabulary.getUri() + config.getNamespace().getTerm().getSeparator() + "/Construction"))).when( + termService).findIdentifierByLabel("Construction", vocabulary, Constants.DEFAULT_LANGUAGE); + + + assertThrows(VocabularyImportException.class, () -> sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-simple-en.xlsx")))); + } } From 27225836b602b9e0c7fa5d3f942fc51979e3f0ab Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 29 Aug 2024 15:44:55 +0200 Subject: [PATCH 047/150] [kbss-cvut/termit-ui#449] Add some explanatory javadoc. --- .../termit/service/importer/excel/ExcelImporter.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index a93e3cd0c..1e8eaa392 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -183,6 +183,17 @@ private String resolveVocabularyTermNamespace(Vocabulary vocabulary) { config.getNamespace().getTerm().getSeparator()); } + /** + * Resolves term identifier. + *

+ * If the term does not have an identifier, it is generated so that existing instance can be removed before + * inserting the imported term. If the term has an identifier, but it does not match the expected vocabulary-based + * namespace, it is adjusted so that it does. Otherwise, the identifier is used. + * + * @param vocabulary Vocabulary into which the term will be added + * @param term The imported term + * @return Term identifier + */ private URI resolveTermIdentifier(Vocabulary vocabulary, Term term) { final String termNamespace = resolveVocabularyTermNamespace(vocabulary); if (term.getUri() == null) { From 01d9e1a3ae094bc72db4c9d737898c8966a02bb6 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 29 Aug 2024 16:16:35 +0200 Subject: [PATCH 048/150] [Upd] Dependency updates. --- pom.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index 85dbb3647..12c871009 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ org.springframework.boot spring-boot-starter-parent - 3.3.1 + 3.3.3 termit @@ -29,10 +29,10 @@ 17 2.7.0 - 1.5.5.Final - 2.2.0 - 2.0.2 - 0.14.3 + 1.6.0 + 2.6.0 + 2.0.4 + 0.15.0 DEV @@ -286,7 +286,7 @@ net.bull.javamelody javamelody-spring-boot-starter - 2.0.0 + 2.2.0 From 9316518314b1fcb166e55a5f7560b404598c4bb0 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 30 Aug 2024 15:44:07 +0200 Subject: [PATCH 049/150] [kbss-cvut/termit-ui#449] Add missing type and state selection lists to the Excel template English sheet. --- .../resources/template/termit-import.xlsx | Bin 56181 -> 59266 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx index 729fd2cb4b9d8df26528eac797c0cd3457196183..e0bb55f124a4cee4d32c9a072d4ff14b629026c8 100644 GIT binary patch delta 12799 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 literal 56181 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 L0L0br+CKe14Q&ld From c1bf8746923b43346f321d66ffe4ac9a91465883 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 30 Aug 2024 16:08:57 +0200 Subject: [PATCH 050/150] [kbss-cvut/termit-ui#449] Support multiple types per term in Excel import. --- pom.xml | 2 +- .../excel/LocalizedSheetImporter.java | 20 +++++++----- .../importer/excel/ExcelImporterTest.java | 31 +++++++++++++++++++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index 12c871009..0841e4f4f 100644 --- a/pom.xml +++ b/pom.xml @@ -112,7 +112,7 @@ org.eclipse.rdf4j rdf4j-rio-rdfxml - 5.0.0 + 5.0.2 diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 6037978f4..67187fc3e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -189,8 +189,8 @@ private void mapRowToTermAttributes(Term term, Row termRow) { rtm -> mapSkosMatchProperties(term, SKOS.RELATED_MATCH, splitIntoMultipleValues(rtm))); getAttributeValue(termRow, SKOS.EXACT_MATCH).ifPresent( exm -> mapSkosMatchProperties(term, SKOS.EXACT_MATCH, splitIntoMultipleValues(exm))); - getAttributeValue(termRow, JsonLd.TYPE).flatMap(t -> resolveTermType(t, term)) - .ifPresent(t -> term.setTypes(Set.of(t))); + getAttributeValue(termRow, JsonLd.TYPE).map(str -> resolveTermTypes(splitIntoMultipleValues(str), term)) + .ifPresent(term::setTypes); resolveTermState(getAttributeValue(termRow, Vocabulary.s_p_ma_stav_pojmu).orElse(null), term).ifPresent( term::setState); @@ -265,15 +265,19 @@ private void mapSkosMatchProperties(Term subject, String property, Set o new ExcelImporter.TermRelationship(subject, propertyUri, new Term(uri)))); } - private Optional resolveTermType(String value, Term term) { + private Set resolveTermTypes(Set values, Term term) { if (!Utils.emptyIfNull(term.getTypes()).isEmpty()) { // Type already present from previous sheet - return Optional.empty(); + return term.getTypes(); } - return languageService.getTermTypes().stream() - .filter(t -> value.equals(t.getLabel().get(langTag)) || value.equals( - t.getUri().toString())).findFirst() - .map(t -> t.getUri().toString()); + final List availableTypes = languageService.getTermTypes(); + return values.stream().map(str -> availableTypes.stream() + .filter(t -> str.equals( + t.getLabel().get(langTag)) || str.equals( + t.getUri().toString())).findFirst() + .map(t -> t.getUri().toString()) + .orElse(str)) + .collect(Collectors.toSet()); } private Optional resolveTermState(String value, Term term) { diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 58c6fb842..3254d2246 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -46,6 +46,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -647,4 +648,34 @@ void importThrowsVocabularyImportExceptionWhenVocabularyAlreadyContainsTermWithS Environment.loadFile( "data/import-simple-en.xlsx")))); } + + @Test + void importSupportsMultipleTypesDeclaredForTerm() throws Exception { + vocabulary.setUri(URI.create("http://example.com")); + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + final Workbook input = new XSSFWorkbook(Environment.loadFile("template/termit-import.xlsx")); + final Sheet englishSheet = input.getSheet("English"); + englishSheet.getRow(1).createCell(0).setCellValue("Construction"); + final Term objectType = Generator.generateTermWithId(); + objectType.setUri(URI.create("http://onto.fel.cvut.cz/ontologies/ufo/object-type")); + objectType.setLabel(MultilingualString.create("Object Type", Constants.DEFAULT_LANGUAGE)); + final Term eventType = Generator.generateTermWithId(); + eventType.setUri(URI.create("http://onto.fel.cvut.cz/ontologies/ufo/event-type")); + eventType.setLabel(MultilingualString.create("Event Type", Constants.DEFAULT_LANGUAGE)); + when(languageService.getTermTypes()).thenReturn(List.of(objectType, eventType)); + englishSheet.getRow(1).createCell(5) + .setCellValue(objectType.getLabel().get() + ";" + eventType.getLabel().get()); + final ByteArrayOutputStream bos = new ByteArrayOutputStream(); + input.write(bos); + + sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + new ByteArrayInputStream(bos.toByteArray()))); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + assertThat(captor.getValue().getTypes(), + hasItems(objectType.getUri().toString(), eventType.getUri().toString())); + } } From f8d8cdfb29576ae4a8f996ac842ed64816fc5783 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 28 Aug 2024 15:59:24 +0200 Subject: [PATCH 051/150] websocket endpoint implementation --- pom.xml | 8 ++ .../kbss/termit/config/SecurityConfig.java | 35 +++++ .../cvut/kbss/termit/config/WebAppConfig.java | 8 +- .../kbss/termit/config/WebSocketConfig.java | 121 ++++++++++++++++++ .../termit/rest/VocabularyController.java | 22 ---- .../WebSocketJwtAuthorizationInterceptor.java | 59 +++++++++ .../kbss/termit/util/ResultWithHeaders.java | 47 +++++++ ...bSocketMessageWithHeadersValueHandler.java | 41 ++++++ .../websocket/VocabularySocketController.java | 54 ++++++++ .../kbss/termit/environment/Environment.java | 7 +- .../BaseWebSocketControllerTestRunner.java | 36 ++++++ .../VocabularySocketControllerTest.java | 110 ++++++++++++++++ .../util/TestAnnotationMethodHandler.java | 25 ++++ .../websocket/util/TestMessageChannel.java | 26 ++++ 14 files changed, 571 insertions(+), 28 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java create mode 100644 src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/ResultWithHeaders.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/WebSocketMessageWithHeadersValueHandler.java create mode 100644 src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java create mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java create mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java create mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/util/TestAnnotationMethodHandler.java create mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/util/TestMessageChannel.java diff --git a/pom.xml b/pom.xml index 0841e4f4f..62ee8fd44 100644 --- a/pom.xml +++ b/pom.xml @@ -149,6 +149,14 @@ org.springframework.boot spring-boot-starter-oauth2-resource-server + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.security + spring-security-messaging + org.springframework.data spring-data-commons diff --git a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java index 85904dce6..a915dd7dd 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java @@ -23,26 +23,34 @@ import cz.cvut.kbss.termit.security.JwtAuthorizationFilter; import cz.cvut.kbss.termit.security.JwtUtils; import cz.cvut.kbss.termit.security.SecurityConstants; +import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import cz.cvut.kbss.termit.util.Constants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.util.AntPathMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -143,4 +151,31 @@ protected static CorsConfigurationSource createCorsConfiguration( source.registerCorsConfiguration("/**", corsConfiguration); return source; } + + @Bean + @Scope("prototype") + public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder( + ApplicationContext context) { + return MessageMatcherDelegatingAuthorizationManager.builder().simpDestPathMatcher( + () -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) + ? context.getBean(SimpAnnotationMethodMessageHandler.class).getPathMatcher() + : new AntPathMatcher()); + } + + /** + * WebSocket authorization + */ + @Bean + public AuthorizationManager> messageAuthorizationManager( + MessageMatcherDelegatingAuthorizationManager.Builder messages) { + return messages.nullDestMatcher().denyAll() + .anyMessage().authenticated() + .anyMessage().hasAuthority(SecurityConstants.ROLE_RESTRICTED_USER) + .build(); + } + + @Bean + public WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor() { + return new WebSocketJwtAuthorizationInterceptor(jwtUtils, userDetailsService); + } } diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java index da01d8ece..61781c11a 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java @@ -153,12 +153,12 @@ public SimpleUrlHandlerMapping sparqlQueryControllerMapping() throws Exception { } @Bean - public HttpMessageConverter stringMessageConverter() { + public HttpMessageConverter termitStringHttpMessageConverter() { return new StringHttpMessageConverter(StandardCharsets.UTF_8); } @Bean - public HttpMessageConverter jsonLdMessageConverter() { + public HttpMessageConverter termitJsonLdHttpMessageConverter() { final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter( jsonLdObjectMapper()); converter.setSupportedMediaTypes(Collections.singletonList(MediaType.valueOf(JsonLd.MEDIA_TYPE))); @@ -166,14 +166,14 @@ public HttpMessageConverter jsonLdMessageConverter() { } @Bean - public HttpMessageConverter jsonMessageConverter() { + public HttpMessageConverter termitJsonHttpMessageConverter() { final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); converter.setObjectMapper(objectMapper()); return converter; } @Bean - public HttpMessageConverter resourceMessageConverter() { + public HttpMessageConverter termitResourceHttpMessageConverter() { return new ResourceHttpMessageConverter(); } diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java new file mode 100644 index 000000000..1929b95a6 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java @@ -0,0 +1,121 @@ +package cz.cvut.kbss.termit.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; +import cz.cvut.kbss.termit.util.WebSocketMessageWithHeadersValueHandler; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.messaging.Message; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.converter.StringMessageConverter; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.SpringAuthorizationEventPublisher; +import org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor; +import org.springframework.security.messaging.context.AuthenticationPrincipalArgumentResolver; +import org.springframework.security.messaging.context.SecurityContextChannelInterceptor; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +/* +We are not using @EnableWebSocketSecurity +it automatically requires CSRF which cannot be configured (disabled) at the moment +(will probably change in the future) +*/ +@Configuration +@EnableWebSocketMessageBroker +@Order(Ordered.HIGHEST_PRECEDENCE + 99) // ensures priority above Spring Security +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final cz.cvut.kbss.termit.util.Configuration configuration; + + private final ApplicationContext context; + + private final AuthorizationManager> messageAuthorizationManager; + + private final WebSocketJwtAuthorizationInterceptor jwtAuthorizationInterceptor; + + private final ObjectMapper jsonLdMapper; + + private final SimpMessagingTemplate simpMessagingTemplate; + + @Autowired + public WebSocketConfig(cz.cvut.kbss.termit.util.Configuration configuration, ApplicationContext context, + AuthorizationManager> messageAuthorizationManager, + WebSocketJwtAuthorizationInterceptor jwtAuthorizationInterceptor, + @Qualifier("jsonLdMapper") ObjectMapper jsonLdMapper, + @Lazy SimpMessagingTemplate simpMessagingTemplate) { + this.configuration = configuration; + this.context = context; + this.messageAuthorizationManager = messageAuthorizationManager; + this.jwtAuthorizationInterceptor = jwtAuthorizationInterceptor; + this.jsonLdMapper = jsonLdMapper; + this.simpMessagingTemplate = simpMessagingTemplate; + } + + /* WebSocket security setup (replaces @EnableWebSocketSecurity) */ + + @Override + public void addArgumentResolvers(List argumentResolvers) { + AuthenticationPrincipalArgumentResolver resolver = new AuthenticationPrincipalArgumentResolver(); + argumentResolvers.add(resolver); + } + + /** + * @see Spring security source + */ + @Override + public void configureClientInboundChannel(@NotNull ChannelRegistration registration) { + AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(this.messageAuthorizationManager); + interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context)); + registration.interceptors(jwtAuthorizationInterceptor, new SecurityContextChannelInterceptor(), interceptor); + } + + @Override + public void addReturnValueHandlers(List returnValueHandlers) { + returnValueHandlers.add(new WebSocketMessageWithHeadersValueHandler(simpMessagingTemplate)); + } + + /* WebSocket endpoint configuration */ + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOrigins(configuration.getCors().getAllowedOrigins().split(",")); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/"); + } + + @Override + public boolean configureMessageConverters(List messageConverters) { + messageConverters.add(termitJsonLdMessageConverter()); + messageConverters.add(termitStringMessageConverter()); + return true; + } + + public MessageConverter termitStringMessageConverter() { + return new StringMessageConverter(StandardCharsets.UTF_8); + } + + public MessageConverter termitJsonLdMessageConverter() { + return new MappingJackson2MessageConverter(jsonLdMapper); + } + +} diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 454f457e2..881e6b71b 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -27,7 +27,6 @@ import cz.cvut.kbss.termit.model.acl.AccessControlRecord; import cz.cvut.kbss.termit.model.acl.AccessLevel; import cz.cvut.kbss.termit.model.changetracking.AbstractChangeRecord; -import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.rest.doc.ApiDocConstants; import cz.cvut.kbss.termit.rest.util.RestUtils; import cz.cvut.kbss.termit.security.SecurityConstants; @@ -413,27 +412,6 @@ public List termsRelations(@Parameter(description = ApiDoc.ID_LOC return vocabularyService.getTermRelations(vocabulary); } - @Operation(description = "Validates the terms in a vocabulary with the specified identifier.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "A collection of validation results."), - @ApiResponse(responseCode = "404", description = ApiDoc.ID_NOT_FOUND_DESCRIPTION) - }) - @PreAuthorize("permitAll()") // TODO Authorize? - @GetMapping(value = "/{localName}/validate", - produces = {MediaType.APPLICATION_JSON_VALUE, JsonLd.MEDIA_TYPE}) - public List validateVocabulary( - @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, - example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) - @PathVariable String localName, - @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, - example = ApiDoc.ID_NAMESPACE_EXAMPLE) - @RequestParam(name = QueryParams.NAMESPACE, - required = false) Optional namespace) { - final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); - final Vocabulary vocabulary = vocabularyService.getReference(identifier); - return vocabularyService.validateContents(vocabulary); - } - @Operation(security = {@SecurityRequirement(name = "bearer-key")}, description = "Creates a snapshot of the vocabulary with the specified identifier.") @ApiResponses({ diff --git a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java new file mode 100644 index 000000000..ae2a23c86 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java @@ -0,0 +1,59 @@ +package cz.cvut.kbss.termit.security; + +import cz.cvut.kbss.termit.exception.AuthorizationException; +import cz.cvut.kbss.termit.exception.JwtException; +import cz.cvut.kbss.termit.security.model.TermItUserDetails; +import cz.cvut.kbss.termit.service.security.SecurityUtils; +import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; +import org.jetbrains.annotations.NotNull; +import org.springframework.http.HttpHeaders; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +public class WebSocketJwtAuthorizationInterceptor implements ChannelInterceptor { + + private final JwtUtils jwtUtils; + + private final TermItUserDetailsService userDetailsService; + + public WebSocketJwtAuthorizationInterceptor(JwtUtils jwtUtils, TermItUserDetailsService userDetailsService) { + this.jwtUtils = jwtUtils; + this.userDetailsService = userDetailsService; + } + + @Override + public Message preSend(@NotNull Message message, @NotNull MessageChannel channel) { + StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (headerAccessor != null && StompCommand.CONNECT.equals(headerAccessor.getCommand()) && headerAccessor.isMutable()) { + final String authHeader = headerAccessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); + if (authHeader != null && authHeader.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) { + headerAccessor.removeNativeHeader(HttpHeaders.AUTHORIZATION); + return process(message, authHeader, headerAccessor); + } + } + return message; + } + + private Message process(final @NotNull Message message, final @NotNull String authHeader, + final @NotNull StompHeaderAccessor headerAccessor) { + final String authToken = authHeader.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); + try { + final TermItUserDetails userDetails = jwtUtils.extractUserInfo(authToken); + final TermItUserDetails existingDetails = userDetailsService.loadUserByUsername(userDetails.getUsername()); + SecurityUtils.verifyAccountStatus(existingDetails.getUser()); + Authentication authentication = SecurityUtils.setCurrentUser(existingDetails); + headerAccessor.setUser(authentication); + return message; + } catch (JwtException | DisabledException | LockedException | UsernameNotFoundException e) { + throw new AuthorizationException(e.getMessage()); + } + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/ResultWithHeaders.java b/src/main/java/cz/cvut/kbss/termit/util/ResultWithHeaders.java new file mode 100644 index 000000000..3d68bd862 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/ResultWithHeaders.java @@ -0,0 +1,47 @@ +package cz.cvut.kbss.termit.util; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.messaging.handler.annotation.SendTo; + +import java.util.HashMap; +import java.util.Map; + +/** + * Wrapper carrying a result from WebSocket controller + * including the {@link #payload}, {@link #destination} and {@link #headers} for the resulting message. + *

+ * Do not combine with other method-return-value handlers (like {@link SendTo @SendTo}) + * + * @param payload The actual result of the method + * @param destination The destination channel where the message will be sent + * @param headers Headers that will overwrite headers in the message. + * @param The type of the payload + * @see WebSocketMessageWithHeadersValueHandler processes results from methods + */ +public record ResultWithHeaders(T payload, @NotNull String destination, @NotNull Map headers) { + + public static ResultWithHeadersBuilder result(T payload) { + return new ResultWithHeadersBuilder<>(payload); + } + + public static class ResultWithHeadersBuilder { + + private final T payload; + + private @Nullable Map headers = null; + + private ResultWithHeadersBuilder(T payload) { + this.payload = payload; + } + + public ResultWithHeadersBuilder withHeaders(@NotNull Map headers) { + this.headers = headers; + return this; + } + + public ResultWithHeaders sendTo(String destination) { + return new ResultWithHeaders<>(payload, destination, headers == null ? new HashMap<>() : headers); + } + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/WebSocketMessageWithHeadersValueHandler.java b/src/main/java/cz/cvut/kbss/termit/util/WebSocketMessageWithHeadersValueHandler.java new file mode 100644 index 000000000..c418c03fc --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/WebSocketMessageWithHeadersValueHandler.java @@ -0,0 +1,41 @@ +package cz.cvut.kbss.termit.util; + +import cz.cvut.kbss.termit.exception.UnsupportedOperationException; +import org.jetbrains.annotations.NotNull; +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.support.MessageHeaderAccessor; + +import java.util.HashMap; +import java.util.Map; + +public class WebSocketMessageWithHeadersValueHandler implements HandlerMethodReturnValueHandler { + + private final SimpMessagingTemplate simpMessagingTemplate; + + public WebSocketMessageWithHeadersValueHandler(SimpMessagingTemplate simpMessagingTemplate) { + this.simpMessagingTemplate = simpMessagingTemplate; + } + + @Override + public boolean supportsReturnType(MethodParameter returnType) { + return ResultWithHeaders.class.isAssignableFrom(returnType.getParameterType()); + } + + @Override + public void handleReturnValue(Object returnValue, @NotNull MethodParameter returnType, @NotNull Message message) + throws Exception { + final MessageHeaderAccessor originalHeadersAccessor = MessageHeaderAccessor.getAccessor(message); + if (originalHeadersAccessor != null && returnValue instanceof ResultWithHeaders resultWithHeaders) { + final Map headers = new HashMap<>(); + headers.putAll(originalHeadersAccessor.toMap()); + headers.putAll(resultWithHeaders.headers()); + + simpMessagingTemplate.convertAndSend(resultWithHeaders.destination(), resultWithHeaders.payload(), headers); + return; + } + throw new UnsupportedOperationException("Unable to process returned value: " + returnValue + " of type " + returnType.getParameterType() + " from " + returnType.getMethod()); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java new file mode 100644 index 000000000..da1eff54b --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -0,0 +1,54 @@ +package cz.cvut.kbss.termit.websocket; + +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.model.validation.ValidationResult; +import cz.cvut.kbss.termit.rest.BaseController; +import cz.cvut.kbss.termit.security.SecurityConstants; +import cz.cvut.kbss.termit.service.IdentifierResolver; +import cz.cvut.kbss.termit.service.business.VocabularyService; +import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.util.Constants; +import cz.cvut.kbss.termit.util.ResultWithHeaders; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static cz.cvut.kbss.termit.util.ResultWithHeaders.result; + +@Controller +@MessageMapping("/vocabularies") +@PreAuthorize("hasRole('" + SecurityConstants.ROLE_RESTRICTED_USER + "')") +public class VocabularySocketController extends BaseController { + + private static final Logger LOG = LoggerFactory.getLogger(VocabularySocketController.class); + + private final VocabularyService vocabularyService; + + protected VocabularySocketController(IdentifierResolver idResolver, Configuration config, + VocabularyService vocabularyService) { + super(idResolver, config); + this.vocabularyService = vocabularyService; + } + + /** + * Validates the terms in a vocabulary with the specified identifier. + */ + @MessageMapping("/{localName}/validate") + public ResultWithHeaders> validateVocabulary(@DestinationVariable String localName, + @Header(name = Constants.QueryParams.NAMESPACE, + required = false) Optional namespace) { + final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); + final Vocabulary vocabulary = vocabularyService.getReference(identifier); + return result(vocabularyService.validateContents(vocabulary)).withHeaders(Map.of("vocabulary", identifier)) + .sendTo("/vocabularies/validation"); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/environment/Environment.java b/src/test/java/cz/cvut/kbss/termit/environment/Environment.java index e9db7449e..9f7bd62a1 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/Environment.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/Environment.java @@ -41,6 +41,7 @@ import org.springframework.http.converter.ResourceHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextImpl; @@ -81,11 +82,13 @@ private static DtoMapper initDtoMapper() { * * @param user User to set as currently authenticated */ - public static void setCurrentUser(UserAccount user) { + public static Authentication setCurrentUser(UserAccount user) { final TermItUserDetails userDetails = new TermItUserDetails(user, new HashSet<>()); SecurityContext context = new SecurityContextImpl(); - context.setAuthentication(new AuthenticationToken(userDetails.getAuthorities(), userDetails)); + Authentication authentication = new AuthenticationToken(userDetails.getAuthorities(), userDetails); + context.setAuthentication(authentication); SecurityContextHolder.setContext(context); + return authentication; } /** diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java new file mode 100644 index 000000000..c59f8af3d --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java @@ -0,0 +1,36 @@ +package cz.cvut.kbss.termit.websocket; + +import cz.cvut.kbss.termit.util.WebSocketMessageWithHeadersValueHandler; +import cz.cvut.kbss.termit.websocket.util.TestAnnotationMethodHandler; +import cz.cvut.kbss.termit.websocket.util.TestMessageChannel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import java.util.List; + +@ExtendWith(MockitoExtension.class) +public abstract class BaseWebSocketControllerTestRunner { + + protected TestMessageChannel clientOutboundChannel; + + protected TestAnnotationMethodHandler annotationMethodHandler; + + @BeforeEach + public void setup() { + this.clientOutboundChannel = new TestMessageChannel(); + this.annotationMethodHandler = new TestAnnotationMethodHandler(new TestMessageChannel(), clientOutboundChannel, new SimpMessagingTemplate(new TestMessageChannel())); + this.annotationMethodHandler.setDestinationPrefixes(List.of("/")); + this.annotationMethodHandler.setMessageConverter(new MappingJackson2MessageConverter()); + this.annotationMethodHandler.setApplicationContext(new StaticApplicationContext()); + this.annotationMethodHandler.setCustomReturnValueHandlers(List.of(new WebSocketMessageWithHeadersValueHandler(new SimpMessagingTemplate(clientOutboundChannel)))); + this.annotationMethodHandler.afterPropertiesSet(); + } + + protected void registerController(Object controller) { + this.annotationMethodHandler.registerHandler(controller); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java new file mode 100644 index 000000000..b9bc23166 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java @@ -0,0 +1,110 @@ +package cz.cvut.kbss.termit.websocket; + +import cz.cvut.kbss.jopa.model.MultilingualString; +import cz.cvut.kbss.termit.environment.Environment; +import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.model.validation.ValidationResult; +import cz.cvut.kbss.termit.service.IdentifierResolver; +import cz.cvut.kbss.termit.service.business.VocabularyService; +import cz.cvut.kbss.termit.util.Configuration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.security.core.Authentication; + +import java.util.HashMap; +import java.util.List; + +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.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +class VocabularySocketControllerTest extends BaseWebSocketControllerTestRunner { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + Configuration configuration = new Configuration(); + + @Mock + IdentifierResolver idResolver; + + @Mock + VocabularyService vocabularyService; + + @InjectMocks + VocabularySocketController sut; + + Vocabulary vocabulary; + + String fragment; + + String namespace; + + StompHeaderAccessor messageHeaders; + + @Override + @BeforeEach + public void setup() { + super.setup(); + vocabulary = Generator.generateVocabularyWithId(); + fragment = IdentifierResolver.extractIdentifierFragment(vocabulary.getUri()).substring(1); + namespace = vocabulary.getUri().toString().substring(0, vocabulary.getUri().toString().lastIndexOf('/')); + when(idResolver.resolveIdentifier(namespace, fragment)).thenReturn(vocabulary.getUri()); + when(vocabularyService.getReference(vocabulary.getUri())).thenReturn(vocabulary); + registerController(sut); + + messageHeaders = StompHeaderAccessor.create(StompCommand.MESSAGE); + messageHeaders.setSessionId("0"); + Authentication auth = Environment.setCurrentUser(Generator.generateUserAccountWithPassword()); + messageHeaders.setUser(auth); + messageHeaders.setSessionAttributes(new HashMap<>()); + } + + @Test + void validateVocabularyValidatesContents() { + messageHeaders.setContentLength(0); + messageHeaders.setHeader("namespace", namespace); + messageHeaders.setDestination("/vocabularies/" + fragment + "/validate"); + + this.annotationMethodHandler.handleMessage(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); + + verify(vocabularyService).validateContents(vocabulary); + } + + @Test + void validateVocabularyReturnsValidationResults() { + messageHeaders.setContentLength(0); + messageHeaders.setHeader("namespace", namespace); + messageHeaders.setDestination("/vocabularies/" + fragment + "/validate"); + + final ValidationResult validationResult = new ValidationResult().setTermUri(Generator.generateUri()) + .setResultPath(Generator.generateUri()) + .setMessage(MultilingualString.create("message", "en")) + .setSeverity(Generator.generateUri()) + .setIssueCauseUri(Generator.generateUri()); + final List validationResults = List.of(validationResult); + when(vocabularyService.validateContents(vocabulary)).thenReturn(validationResults); + + this.annotationMethodHandler.handleMessage(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); + + assertEquals(1, this.clientOutboundChannel.getMessages().size()); + Message reply = this.clientOutboundChannel.getMessages().get(0); + + assertNotNull(reply); + StompHeaderAccessor replyHeaders = StompHeaderAccessor.wrap(reply); + // as reply is sent to a common channel for all vocabularies, there must be header with vocabulary uri + assertEquals(vocabulary.getUri(), replyHeaders.getHeader("vocabulary"), "Invalid or missing vocabulary header in the reply"); + + assertInstanceOf(List.class, reply.getPayload()); + assertEquals(validationResults, reply.getPayload()); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/TestAnnotationMethodHandler.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/TestAnnotationMethodHandler.java new file mode 100644 index 000000000..c1b08a6c8 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/websocket/util/TestAnnotationMethodHandler.java @@ -0,0 +1,25 @@ +package cz.cvut.kbss.termit.websocket.util; + +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; + +/** + * An extension of SimpAnnotationMethodMessageHandler that exposes a (public) + * method for manually registering a controller, rather than having it + * auto-discovered in the Spring ApplicationContext. + * @author Rossen Stoyanchev + */ +public class TestAnnotationMethodHandler extends SimpAnnotationMethodMessageHandler { + + public TestAnnotationMethodHandler(SubscribableChannel inChannel, MessageChannel outChannel, + SimpMessageSendingOperations brokerTemplate) { + + super(inChannel, outChannel, brokerTemplate); + } + + public void registerHandler(Object handler) { + super.detectHandlerMethods(handler); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/TestMessageChannel.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/TestMessageChannel.java new file mode 100644 index 000000000..e959f34be --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/websocket/util/TestMessageChannel.java @@ -0,0 +1,26 @@ +package cz.cvut.kbss.termit.websocket.util; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.AbstractSubscribableChannel; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Rossen Stoyanchev + */ +public class TestMessageChannel extends AbstractSubscribableChannel { + + private final List> messages = new ArrayList<>(); + + public List> getMessages() { + return this.messages; + } + + @Override + protected boolean sendInternal(Message message, long timeout) { + this.messages.add(message); + return true; + } + +} From 36de8cba22df7531c02302689d671a7f039bf5ee Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 29 Aug 2024 12:06:19 +0200 Subject: [PATCH 052/150] websocket testing --- .../config/TestWebsocketConfig.java | 100 ++++++++++++++++++ .../BaseWebSocketControllerTestRunner.java | 98 +++++++++++++---- .../VocabularySocketControllerTest.java | 35 +++--- .../util/CachingChannelInterceptor.java | 28 +++++ ...nValueCollectingSimpMessagingTemplate.java | 38 +++++++ .../util/TestAnnotationMethodHandler.java | 25 ----- .../websocket/util/TestMessageChannel.java | 26 ----- 7 files changed, 260 insertions(+), 90 deletions(-) create mode 100644 src/test/java/cz/cvut/kbss/termit/environment/config/TestWebsocketConfig.java create mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java create mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java delete mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/util/TestAnnotationMethodHandler.java delete mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/util/TestMessageChannel.java diff --git a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebsocketConfig.java b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebsocketConfig.java new file mode 100644 index 000000000..8ef28a0e1 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebsocketConfig.java @@ -0,0 +1,100 @@ +package cz.cvut.kbss.termit.environment.config; + +import cz.cvut.kbss.termit.config.WebAppConfig; +import cz.cvut.kbss.termit.config.WebSocketConfig; +import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.websocket.util.ReturnValueCollectingSimpMessagingTemplate; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Lookup; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Primary; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.support.AbstractSubscribableChannel; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@TestConfiguration +@EnableConfigurationProperties(Configuration.class) +@Import({TestSecurityConfig.class, TestRestSecurityConfig.class, WebAppConfig.class, WebSocketConfig.class}) +@ComponentScan(basePackages = "cz.cvut.kbss.termit.websocket") +public class TestWebsocketConfig + implements ApplicationListener, WebSocketMessageBrokerConfigurer { + + private final List channels; + + private final List handlers; + + @Autowired + @Lazy + public TestWebsocketConfig(List channels, List handlers) { + this.channels = channels; + this.handlers = handlers; + } + + /** + * Unregisters MessageHandler's from the message channels to reduce processing during the test + * + * @param event the event to respond to + */ + @Override + public void onApplicationEvent(@NotNull ContextRefreshedEvent event) { + for (MessageHandler handler : handlers) { + if (handler instanceof SimpAnnotationMethodMessageHandler) { + continue; + } + for (SubscribableChannel channel : channels) { + channel.unsubscribe(handler); + } + } + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.executor(new SyncTaskExecutor()); + } + + @Override + public void configureClientOutboundChannel(ChannelRegistration registration) { + registration.executor(new SyncTaskExecutor()); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.configureBrokerChannel().executor(new SyncTaskExecutor()); + } + + @Bean + public Map returnedValuesMap() { + return new HashMap<>(); + } + + @Bean + @Primary + public SimpMessagingTemplate brokerMessagingTemplate( + AbstractSubscribableChannel brokerChannel, CompositeMessageConverter brokerMessageConverter) { + + SimpMessagingTemplate template = new ReturnValueCollectingSimpMessagingTemplate(brokerChannel, returnedValuesMap()); + template.setMessageConverter(brokerMessageConverter); + return template; + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java index c59f8af3d..2793e0e0b 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java @@ -1,36 +1,96 @@ package cz.cvut.kbss.termit.websocket; -import cz.cvut.kbss.termit.util.WebSocketMessageWithHeadersValueHandler; -import cz.cvut.kbss.termit.websocket.util.TestAnnotationMethodHandler; -import cz.cvut.kbss.termit.websocket.util.TestMessageChannel; +import cz.cvut.kbss.termit.environment.config.TestWebsocketConfig; +import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.websocket.util.CachingChannelInterceptor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.support.StaticApplicationContext; -import org.springframework.messaging.converter.MappingJackson2MessageConverter; -import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.AbstractSubscribableChannel; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; -import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import static cz.cvut.kbss.termit.websocket.util.ReturnValueCollectingSimpMessagingTemplate.MESSAGE_IDENTIFIER_HEADER; + +@ActiveProfiles("test") +@ExtendWith(SpringExtension.class) @ExtendWith(MockitoExtension.class) +@EnableConfigurationProperties({Configuration.class}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@ContextConfiguration(classes = {TestWebsocketConfig.class}, + initializers = {ConfigDataApplicationContextInitializer.class}) public abstract class BaseWebSocketControllerTestRunner { - protected TestMessageChannel clientOutboundChannel; + private static final Logger LOG = LoggerFactory.getLogger(BaseWebSocketControllerTestRunner.class); + + /** + * Simulated messages from client to server + */ + @Autowired + @Qualifier("clientInboundChannel") + protected AbstractSubscribableChannel serverInboundChannel; + + /** + * Messages sent from the server to the client + */ + @Autowired + @Qualifier("clientOutboundChannel") + protected AbstractSubscribableChannel serverOutboundChannel; + + @Autowired + protected AbstractSubscribableChannel brokerChannel; + + /** + * Holds message ids mapped to the values returned from the controllers + */ + @Autowired + protected Map returnedValuesMap; + + /** + * Caches any messages sent from the server to the client + */ + protected CachingChannelInterceptor serverOutboundChannelInterceptor; + + protected CachingChannelInterceptor brokerChannelInterceptor; - protected TestAnnotationMethodHandler annotationMethodHandler; @BeforeEach - public void setup() { - this.clientOutboundChannel = new TestMessageChannel(); - this.annotationMethodHandler = new TestAnnotationMethodHandler(new TestMessageChannel(), clientOutboundChannel, new SimpMessagingTemplate(new TestMessageChannel())); - this.annotationMethodHandler.setDestinationPrefixes(List.of("/")); - this.annotationMethodHandler.setMessageConverter(new MappingJackson2MessageConverter()); - this.annotationMethodHandler.setApplicationContext(new StaticApplicationContext()); - this.annotationMethodHandler.setCustomReturnValueHandlers(List.of(new WebSocketMessageWithHeadersValueHandler(new SimpMessagingTemplate(clientOutboundChannel)))); - this.annotationMethodHandler.afterPropertiesSet(); + protected void runnerSetup() { + this.brokerChannelInterceptor = new CachingChannelInterceptor(); + this.serverOutboundChannelInterceptor = new CachingChannelInterceptor(); + + this.brokerChannel.addInterceptor(this.brokerChannelInterceptor); + this.serverOutboundChannel.addInterceptor(this.serverOutboundChannelInterceptor); } - protected void registerController(Object controller) { - this.annotationMethodHandler.registerHandler(controller); + /** + * Returns result of controller method associated with the specified message. + * + * @param message The message sent from some controller + */ + @SuppressWarnings("unchecked") + protected Optional readPayload(Message message) throws ClassCastException { + final UUID id = message.getHeaders().get(MESSAGE_IDENTIFIER_HEADER, UUID.class); + if (id == null) { + LOG.error("Unable to read message payload. Message id is null."); + return Optional.empty(); + } + if (returnedValuesMap.containsKey(id)) { + return Optional.of((T) returnedValuesMap.get(id)); + } + return Optional.empty(); } } diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java index b9bc23166..8e49c37c6 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java @@ -7,12 +7,10 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.VocabularyService; -import cz.cvut.kbss.termit.util.Configuration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Answers; -import org.mockito.InjectMocks; -import org.mockito.Mock; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.messaging.Message; import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; @@ -21,26 +19,24 @@ import java.util.HashMap; import java.util.List; +import java.util.Optional; 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.assertTrue; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class VocabularySocketControllerTest extends BaseWebSocketControllerTestRunner { - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - Configuration configuration = new Configuration(); - - @Mock + @MockBean IdentifierResolver idResolver; - @Mock + @MockBean VocabularyService vocabularyService; - @InjectMocks + @SpyBean VocabularySocketController sut; Vocabulary vocabulary; @@ -51,19 +47,17 @@ class VocabularySocketControllerTest extends BaseWebSocketControllerTestRunner { StompHeaderAccessor messageHeaders; - @Override @BeforeEach public void setup() { - super.setup(); vocabulary = Generator.generateVocabularyWithId(); fragment = IdentifierResolver.extractIdentifierFragment(vocabulary.getUri()).substring(1); namespace = vocabulary.getUri().toString().substring(0, vocabulary.getUri().toString().lastIndexOf('/')); when(idResolver.resolveIdentifier(namespace, fragment)).thenReturn(vocabulary.getUri()); when(vocabularyService.getReference(vocabulary.getUri())).thenReturn(vocabulary); - registerController(sut); messageHeaders = StompHeaderAccessor.create(StompCommand.MESSAGE); messageHeaders.setSessionId("0"); + messageHeaders.setSubscriptionId("0"); Authentication auth = Environment.setCurrentUser(Generator.generateUserAccountWithPassword()); messageHeaders.setUser(auth); messageHeaders.setSessionAttributes(new HashMap<>()); @@ -75,7 +69,7 @@ void validateVocabularyValidatesContents() { messageHeaders.setHeader("namespace", namespace); messageHeaders.setDestination("/vocabularies/" + fragment + "/validate"); - this.annotationMethodHandler.handleMessage(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); + this.serverInboundChannel.send(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); verify(vocabularyService).validateContents(vocabulary); } @@ -94,17 +88,18 @@ void validateVocabularyReturnsValidationResults() { final List validationResults = List.of(validationResult); when(vocabularyService.validateContents(vocabulary)).thenReturn(validationResults); - this.annotationMethodHandler.handleMessage(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); + this.serverInboundChannel.send(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); - assertEquals(1, this.clientOutboundChannel.getMessages().size()); - Message reply = this.clientOutboundChannel.getMessages().get(0); + assertEquals(1, this.brokerChannelInterceptor.getMessages().size()); + Message reply = this.brokerChannelInterceptor.getMessages().get(0); assertNotNull(reply); StompHeaderAccessor replyHeaders = StompHeaderAccessor.wrap(reply); // as reply is sent to a common channel for all vocabularies, there must be header with vocabulary uri assertEquals(vocabulary.getUri(), replyHeaders.getHeader("vocabulary"), "Invalid or missing vocabulary header in the reply"); - assertInstanceOf(List.class, reply.getPayload()); - assertEquals(validationResults, reply.getPayload()); + Optional> payload = readPayload(reply); + assertTrue(payload.isPresent()); + assertEquals(validationResults, payload.get()); } } diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java new file mode 100644 index 000000000..ff29065f1 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java @@ -0,0 +1,28 @@ +package cz.cvut.kbss.termit.websocket.util; + +import org.jetbrains.annotations.NotNull; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.support.ChannelInterceptor; + +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +/** + * Caches any message sent to the intercepted channel + */ +public class CachingChannelInterceptor implements ChannelInterceptor { + + private final BlockingQueue> messages = new ArrayBlockingQueue<>(100); + + @Override + public Message preSend(@NotNull Message message, @NotNull MessageChannel channel) { + this.messages.add(message); + return message; + } + + public List> getMessages() { + return List.copyOf(messages); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java new file mode 100644 index 000000000..22be11a58 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java @@ -0,0 +1,38 @@ +package cz.cvut.kbss.termit.websocket.util; + +import org.jetbrains.annotations.NotNull; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.core.MessagePostProcessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.support.MessageBuilder; + +import java.util.Map; +import java.util.UUID; + +public class ReturnValueCollectingSimpMessagingTemplate extends SimpMessagingTemplate { + + public static final String MESSAGE_IDENTIFIER_HEADER = "test-message-id"; + + private final Map returnedValuesMap; + + public ReturnValueCollectingSimpMessagingTemplate(MessageChannel messageChannel, + Map returnedValuesMap) { + super(messageChannel); + this.returnedValuesMap = returnedValuesMap; + } + + @Override + protected @NotNull Message doConvert(@NotNull Object payload, Map headers, + MessagePostProcessor postProcessor) { + final Message converted = super.doConvert(payload, headers, postProcessor); + + UUID id = converted.getHeaders().getId(); + if (id == null) { + id = UUID.randomUUID(); + } + + returnedValuesMap.put(id, payload); + return MessageBuilder.fromMessage(converted).copyHeaders(Map.of(MESSAGE_IDENTIFIER_HEADER, id)).build(); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/TestAnnotationMethodHandler.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/TestAnnotationMethodHandler.java deleted file mode 100644 index c1b08a6c8..000000000 --- a/src/test/java/cz/cvut/kbss/termit/websocket/util/TestAnnotationMethodHandler.java +++ /dev/null @@ -1,25 +0,0 @@ -package cz.cvut.kbss.termit.websocket.util; - -import org.springframework.messaging.MessageChannel; -import org.springframework.messaging.SubscribableChannel; -import org.springframework.messaging.simp.SimpMessageSendingOperations; -import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; - -/** - * An extension of SimpAnnotationMethodMessageHandler that exposes a (public) - * method for manually registering a controller, rather than having it - * auto-discovered in the Spring ApplicationContext. - * @author Rossen Stoyanchev - */ -public class TestAnnotationMethodHandler extends SimpAnnotationMethodMessageHandler { - - public TestAnnotationMethodHandler(SubscribableChannel inChannel, MessageChannel outChannel, - SimpMessageSendingOperations brokerTemplate) { - - super(inChannel, outChannel, brokerTemplate); - } - - public void registerHandler(Object handler) { - super.detectHandlerMethods(handler); - } -} diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/TestMessageChannel.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/TestMessageChannel.java deleted file mode 100644 index e959f34be..000000000 --- a/src/test/java/cz/cvut/kbss/termit/websocket/util/TestMessageChannel.java +++ /dev/null @@ -1,26 +0,0 @@ -package cz.cvut.kbss.termit.websocket.util; - -import org.springframework.messaging.Message; -import org.springframework.messaging.support.AbstractSubscribableChannel; - -import java.util.ArrayList; -import java.util.List; - -/** - * @author Rossen Stoyanchev - */ -public class TestMessageChannel extends AbstractSubscribableChannel { - - private final List> messages = new ArrayList<>(); - - public List> getMessages() { - return this.messages; - } - - @Override - protected boolean sendInternal(Message message, long timeout) { - this.messages.add(message); - return true; - } - -} From 17622a88dc0688b3d19c29f8fbb5eaf19761766e Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 29 Aug 2024 12:20:45 +0200 Subject: [PATCH 053/150] test reset --- .../termit/rest/VocabularyControllerTest.java | 24 ------------------- .../BaseWebSocketControllerTestRunner.java | 15 +++++++++--- .../util/CachingChannelInterceptor.java | 4 ++++ 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index cccd38e09..76b2e0521 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -467,30 +467,6 @@ void getHistoryOfContentReturnsListOfAggregatedChangeObjectsForTermsInSpecifiedV verify(serviceMock).getChangesOfContent(vocabulary); } - @Test - void validateExecutesServiceValidate() throws Exception { - final Vocabulary vocabulary = generateVocabularyAndInitReferenceResolution(); - final List records = Collections.singletonList(new ValidationResult() - .setTermUri(Generator.generateUri()) - .setIssueCauseUri( - Generator.generateUri()) - .setSeverity(URI.create( - SH.Violation.toString()))); - when(serviceMock.validateContents(vocabulary)).thenReturn(records); - - - final MvcResult mvcResult = mockMvc.perform(get(PATH + "/" + FRAGMENT + "/validate")) - .andExpect(status().isOk()) - .andReturn(); - final List result = - readValue(mvcResult, new TypeReference>() { - }); - assertNotNull(result); - assertEquals(records.stream().map(ValidationResult::getId).collect(Collectors.toList()), - result.stream().map(ValidationResult::getId).collect(Collectors.toList())); - verify(serviceMock).validateContents(vocabulary); - } - private Vocabulary generateVocabularyAndInitReferenceResolution() { final Vocabulary vocabulary = generateVocabulary(); vocabulary.setUri(VOCABULARY_URI); diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java index 2793e0e0b..11b13c5df 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java @@ -3,6 +3,8 @@ import cz.cvut.kbss.termit.environment.config.TestWebsocketConfig; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.websocket.util.CachingChannelInterceptor; +import jakarta.annotation.PostConstruct; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @@ -17,6 +19,7 @@ import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.event.annotation.BeforeTestClass; import org.springframework.test.context.junit.jupiter.SpringExtension; import java.util.Map; @@ -66,9 +69,8 @@ public abstract class BaseWebSocketControllerTestRunner { protected CachingChannelInterceptor brokerChannelInterceptor; - - @BeforeEach - protected void runnerSetup() { + @PostConstruct + protected void runnerPostConstruct() { this.brokerChannelInterceptor = new CachingChannelInterceptor(); this.serverOutboundChannelInterceptor = new CachingChannelInterceptor(); @@ -76,6 +78,13 @@ protected void runnerSetup() { this.serverOutboundChannel.addInterceptor(this.serverOutboundChannelInterceptor); } + @BeforeEach + protected void runnerBeforeEach() { + this.serverOutboundChannelInterceptor.reset(); + this.brokerChannelInterceptor.reset(); + this.returnedValuesMap.clear(); + } + /** * Returns result of controller method associated with the specified message. * diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java index ff29065f1..43a454b81 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java @@ -22,6 +22,10 @@ public Message preSend(@NotNull Message message, @NotNull MessageChannel c return message; } + public void reset() { + this.messages.clear(); + } + public List> getMessages() { return List.copyOf(messages); } From e6ab64bbf172b344856accbb8e43e26045149b38 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 29 Aug 2024 14:40:35 +0200 Subject: [PATCH 054/150] fix socket authorization --- .../java/cz/cvut/kbss/termit/config/SecurityConfig.java | 5 ++++- .../java/cz/cvut/kbss/termit/config/WebSocketConfig.java | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java index a915dd7dd..fde8aa9c5 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java @@ -37,6 +37,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.messaging.Message; +import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; @@ -168,7 +169,9 @@ public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorization @Bean public AuthorizationManager> messageAuthorizationManager( MessageMatcherDelegatingAuthorizationManager.Builder messages) { - return messages.nullDestMatcher().denyAll() + return messages.simpTypeMatchers(SimpMessageType.CONNECT).authenticated() + .simpTypeMatchers(SimpMessageType.DISCONNECT).authenticated() + .nullDestMatcher().denyAll() .anyMessage().authenticated() .anyMessage().hasAuthority(SecurityConstants.ROLE_RESTRICTED_USER) .build(); diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java index 1929b95a6..e5b32adb8 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java @@ -28,6 +28,7 @@ import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import java.nio.charset.StandardCharsets; import java.util.List; @@ -103,6 +104,11 @@ public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/"); } + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registry) { + registry.setTimeToFirstMessage(15 * 1000); /* client has 15s to send any message */ + } + @Override public boolean configureMessageConverters(List messageConverters) { messageConverters.add(termitJsonLdMessageConverter()); From 0ced0600873185dfdc1d02de2073b3fb72f432f2 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Fri, 30 Aug 2024 15:12:10 +0200 Subject: [PATCH 055/150] exception handling, fix message with headers --- .../kbss/termit/config/SecurityConfig.java | 8 +- .../kbss/termit/config/WebSocketConfig.java | 11 +- .../rest/handler/RestExceptionHandler.java | 15 +- .../WebSocketJwtAuthorizationInterceptor.java | 1 + .../cz/cvut/kbss/termit/util/Constants.java | 11 + .../ResultWithHeaders.java | 22 +- .../websocket/VocabularySocketController.java | 6 +- .../handler/StompExceptionHandler.java | 21 ++ .../handler/WebSocketExceptionHandler.java | 199 ++++++++++++++++++ ...bSocketMessageWithHeadersValueHandler.java | 29 +-- 10 files changed, 285 insertions(+), 38 deletions(-) rename src/main/java/cz/cvut/kbss/termit/{util => websocket}/ResultWithHeaders.java (62%) create mode 100644 src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java create mode 100644 src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java rename src/main/java/cz/cvut/kbss/termit/{util => websocket/handler}/WebSocketMessageWithHeadersValueHandler.java (50%) diff --git a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java index fde8aa9c5..d9ff49012 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java @@ -169,12 +169,8 @@ public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorization @Bean public AuthorizationManager> messageAuthorizationManager( MessageMatcherDelegatingAuthorizationManager.Builder messages) { - return messages.simpTypeMatchers(SimpMessageType.CONNECT).authenticated() - .simpTypeMatchers(SimpMessageType.DISCONNECT).authenticated() - .nullDestMatcher().denyAll() - .anyMessage().authenticated() - .anyMessage().hasAuthority(SecurityConstants.ROLE_RESTRICTED_USER) - .build(); + return messages.simpTypeMatchers(SimpMessageType.DISCONNECT).permitAll() + .anyMessage().authenticated().build(); } @Bean diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java index e5b32adb8..6c42b5ce2 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; -import cz.cvut.kbss.termit.util.WebSocketMessageWithHeadersValueHandler; +import cz.cvut.kbss.termit.util.Constants; +import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; +import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -97,16 +99,19 @@ public void addReturnValueHandlers(List returnV @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").setAllowedOrigins(configuration.getCors().getAllowedOrigins().split(",")); + registry.setErrorHandler(new StompExceptionHandler()); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.setApplicationDestinationPrefixes("/"); + registry.setApplicationDestinationPrefixes("/") + .setUserDestinationPrefix("/user"); } @Override public void configureWebSocketTransport(WebSocketTransportRegistration registry) { - registry.setTimeToFirstMessage(15 * 1000); /* client has 15s to send any message */ + registry.setTimeToFirstMessage(Constants.WEBSOCKET_TIME_TO_FIRST_MESSAGE); + registry.setSendBufferSizeLimit(Constants.WEBSOCKET_SEND_BUFFER_SIZE_LIMIT); } @Override diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index c17a253d1..bf1a88cd9 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -39,6 +39,7 @@ import cz.cvut.kbss.termit.exception.WebServiceIntegrationException; import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -48,8 +49,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; -import jakarta.servlet.http.HttpServletRequest; - /** * Exception handlers for REST controllers. *

@@ -60,28 +59,28 @@ @RestControllerAdvice public class RestExceptionHandler { - private static final Logger LOG = LoggerFactory.getLogger(RestExceptionHandler.class); + private final Logger LOG = LoggerFactory.getLogger(this.getClass()); - private static void logException(TermItException ex, HttpServletRequest request) { + private void logException(TermItException ex, HttpServletRequest request) { if (shouldSuppressLogging(ex)) { return; } logException("Exception caught when processing request to '" + request.getRequestURI() + "'.", ex); } - private static boolean shouldSuppressLogging(TermItException ex) { + private boolean shouldSuppressLogging(TermItException ex) { return ex.getClass().getAnnotation(SuppressibleLogging.class) != null; } - private static void logException(Throwable ex, HttpServletRequest request) { + private void logException(Throwable ex, HttpServletRequest request) { logException("Exception caught when processing request to '" + request.getRequestURI() + "'.", ex); } - private static void logException(String message, Throwable ex) { + private void logException(String message, Throwable ex) { LOG.error(message, ex); } - private static ErrorInfo errorInfo(HttpServletRequest request, Throwable e) { + private ErrorInfo errorInfo(HttpServletRequest request, Throwable e) { return ErrorInfo.createWithMessage(e.getMessage(), request.getRequestURI()); } diff --git a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java index ae2a23c86..3acfc875e 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java +++ b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java @@ -38,6 +38,7 @@ public Message preSend(@NotNull Message message, @NotNull MessageChannel c headerAccessor.removeNativeHeader(HttpHeaders.AUTHORIZATION); return process(message, authHeader, headerAccessor); } + throw new AuthorizationException("Authorization header is invalid"); } return message; } diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index d46203655..fb0959d8f 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -243,4 +243,15 @@ private QueryParams() { throw new AssertionError(); } } + + /** + * the maximum amount of data to buffer when sending messages to a WebSocket session + */ + public static final int WEBSOCKET_SEND_BUFFER_SIZE_LIMIT = Integer.MAX_VALUE; + + /** + * Set the maximum time allowed in milliseconds after the WebSocket connection is established + * and before the first sub-protocol message is received. + */ + public static final int WEBSOCKET_TIME_TO_FIRST_MESSAGE = 15 * 1000 /* 15s */; } diff --git a/src/main/java/cz/cvut/kbss/termit/util/ResultWithHeaders.java b/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java similarity index 62% rename from src/main/java/cz/cvut/kbss/termit/util/ResultWithHeaders.java rename to src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java index 3d68bd862..c89285c46 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/ResultWithHeaders.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java @@ -1,9 +1,11 @@ -package cz.cvut.kbss.termit.util; +package cz.cvut.kbss.termit.websocket; +import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.messaging.handler.annotation.SendTo; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -19,7 +21,8 @@ * @param The type of the payload * @see WebSocketMessageWithHeadersValueHandler processes results from methods */ -public record ResultWithHeaders(T payload, @NotNull String destination, @NotNull Map headers) { +public record ResultWithHeaders(T payload, @NotNull String destination, @NotNull Map headers, + boolean toUser) { public static ResultWithHeadersBuilder result(T payload) { return new ResultWithHeadersBuilder<>(payload); @@ -29,19 +32,28 @@ public static class ResultWithHeadersBuilder { private final T payload; - private @Nullable Map headers = null; + private @Nullable Map headers = null; private ResultWithHeadersBuilder(T payload) { this.payload = payload; } + /** + * All values will be mapped to strings with {@link Object#toString()} + */ public ResultWithHeadersBuilder withHeaders(@NotNull Map headers) { - this.headers = headers; + this.headers = new HashMap<>(); + headers.forEach((key, value) -> this.headers.put(key, value.toString())); + this.headers = Collections.unmodifiableMap(this.headers); return this; } public ResultWithHeaders sendTo(String destination) { - return new ResultWithHeaders<>(payload, destination, headers == null ? new HashMap<>() : headers); + return new ResultWithHeaders<>(payload, destination, headers == null ? Map.of() : headers, false); + } + + public ResultWithHeaders sendToUser(String userDestination) { + return new ResultWithHeaders<>(payload, userDestination, headers == null ? Map.of() : headers, true); } } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index da1eff54b..cbab5b349 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -8,7 +8,6 @@ import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; -import cz.cvut.kbss.termit.util.ResultWithHeaders; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.handler.annotation.DestinationVariable; @@ -22,13 +21,12 @@ import java.util.Map; import java.util.Optional; -import static cz.cvut.kbss.termit.util.ResultWithHeaders.result; +import static cz.cvut.kbss.termit.websocket.ResultWithHeaders.result; @Controller @MessageMapping("/vocabularies") @PreAuthorize("hasRole('" + SecurityConstants.ROLE_RESTRICTED_USER + "')") public class VocabularySocketController extends BaseController { - private static final Logger LOG = LoggerFactory.getLogger(VocabularySocketController.class); private final VocabularyService vocabularyService; @@ -49,6 +47,6 @@ public ResultWithHeaders> validateVocabulary(@Destination final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); final Vocabulary vocabulary = vocabularyService.getReference(identifier); return result(vocabularyService.validateContents(vocabulary)).withHeaders(Map.of("vocabulary", identifier)) - .sendTo("/vocabularies/validation"); + .sendToUser("/vocabularies/validation"); } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java new file mode 100644 index 000000000..4f78bf920 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java @@ -0,0 +1,21 @@ +package cz.cvut.kbss.termit.websocket.handler; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler; + +public class StompExceptionHandler extends StompSubProtocolErrorHandler { + + private static final Logger LOG = LoggerFactory.getLogger(StompExceptionHandler.class); + + @Override + protected @NotNull Message handleInternal(@NotNull StompHeaderAccessor errorHeaderAccessor, + byte @NotNull [] errorPayload, + Throwable cause, StompHeaderAccessor clientHeaderAccessor) { + LOG.error("STOMP sub-protocol exception", cause); + return super.handleInternal(errorHeaderAccessor, errorPayload, cause, clientHeaderAccessor); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java new file mode 100644 index 000000000..69cdceb2c --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -0,0 +1,199 @@ +package cz.cvut.kbss.termit.websocket.handler; + +import cz.cvut.kbss.jopa.exceptions.EntityNotFoundException; +import cz.cvut.kbss.jopa.exceptions.OWLPersistenceException; +import cz.cvut.kbss.jsonld.exception.JsonLdException; +import cz.cvut.kbss.termit.exception.AnnotationGenerationException; +import cz.cvut.kbss.termit.exception.AssetRemovalException; +import cz.cvut.kbss.termit.exception.AuthorizationException; +import cz.cvut.kbss.termit.exception.InvalidLanguageConstantException; +import cz.cvut.kbss.termit.exception.InvalidParameterException; +import cz.cvut.kbss.termit.exception.InvalidPasswordChangeRequestException; +import cz.cvut.kbss.termit.exception.InvalidTermStateException; +import cz.cvut.kbss.termit.exception.NotFoundException; +import cz.cvut.kbss.termit.exception.PersistenceException; +import cz.cvut.kbss.termit.exception.ResourceExistsException; +import cz.cvut.kbss.termit.exception.SnapshotNotEditableException; +import cz.cvut.kbss.termit.exception.TermItException; +import cz.cvut.kbss.termit.exception.UnsupportedOperationException; +import cz.cvut.kbss.termit.exception.UnsupportedSearchFacetException; +import cz.cvut.kbss.termit.exception.ValidationException; +import cz.cvut.kbss.termit.exception.WebServiceIntegrationException; +import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; +import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; +import cz.cvut.kbss.termit.rest.handler.ErrorInfo; +import cz.cvut.kbss.termit.rest.handler.RestExceptionHandler; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageDeliveryException; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +@Component +@ControllerAdvice +public class WebSocketExceptionHandler extends RestExceptionHandler { + + private static final Logger LOG = LoggerFactory.getLogger(WebSocketExceptionHandler.class); + + @MessageExceptionHandler + public void messageDeliveryException(Message message, MessageDeliveryException e) { + final StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message); + LOG.error("Failed to send message with destination {}: {}", headerAccessor.getDestination(), e.getMessage()); + } + + @Override + @MessageExceptionHandler + public ResponseEntity persistenceException(HttpServletRequest request, PersistenceException e) { + return super.persistenceException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity jopaException(HttpServletRequest request, OWLPersistenceException e) { + return super.jopaException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity resourceExistsException(HttpServletRequest request, ResourceExistsException e) { + return super.resourceExistsException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity resourceNotFound(HttpServletRequest request, NotFoundException e) { + return super.resourceNotFound(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity usernameNotFound(HttpServletRequest request, UsernameNotFoundException e) { + return super.usernameNotFound(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity entityNotFoundException(HttpServletRequest request, EntityNotFoundException e) { + return super.entityNotFoundException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity authorizationException(HttpServletRequest request, AuthorizationException e) { + return super.authorizationException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity validationException(HttpServletRequest request, ValidationException e) { + return super.validationException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity webServiceIntegrationException(HttpServletRequest request, + WebServiceIntegrationException e) { + return super.webServiceIntegrationException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity annotationGenerationException(HttpServletRequest request, + AnnotationGenerationException e) { + return super.annotationGenerationException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity termItException(HttpServletRequest request, TermItException e) { + return super.termItException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity jsonLdException(HttpServletRequest request, JsonLdException e) { + return super.jsonLdException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity unsupportedAssetOperationException(HttpServletRequest request, + UnsupportedOperationException e) { + return super.unsupportedAssetOperationException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity vocabularyImportException(HttpServletRequest request, + VocabularyImportException e) { + return super.vocabularyImportException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity unsupportedImportMediaTypeException(HttpServletRequest request, + UnsupportedImportMediaTypeException e) { + return super.unsupportedImportMediaTypeException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity assetRemovalException(HttpServletRequest request, AssetRemovalException e) { + return super.assetRemovalException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity invalidParameter(HttpServletRequest request, InvalidParameterException e) { + return super.invalidParameter(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity maxUploadSizeExceededException(HttpServletRequest request, + MaxUploadSizeExceededException e) { + return super.maxUploadSizeExceededException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity snapshotNotEditableException(HttpServletRequest request, + SnapshotNotEditableException e) { + return super.snapshotNotEditableException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity unsupportedSearchFacetException(HttpServletRequest request, + UnsupportedSearchFacetException e) { + return super.unsupportedSearchFacetException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity invalidLanguageConstantException(HttpServletRequest request, + InvalidLanguageConstantException e) { + return super.invalidLanguageConstantException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity invalidTermStateException(HttpServletRequest request, + InvalidTermStateException e) { + return super.invalidTermStateException(request, e); + } + + @Override + @MessageExceptionHandler + public ResponseEntity invalidPasswordChangeRequestException(HttpServletRequest request, + InvalidPasswordChangeRequestException e) { + return super.invalidPasswordChangeRequestException(request, e); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/WebSocketMessageWithHeadersValueHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java similarity index 50% rename from src/main/java/cz/cvut/kbss/termit/util/WebSocketMessageWithHeadersValueHandler.java rename to src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java index c418c03fc..5494294f2 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/WebSocketMessageWithHeadersValueHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java @@ -1,15 +1,15 @@ -package cz.cvut.kbss.termit.util; +package cz.cvut.kbss.termit.websocket.handler; import cz.cvut.kbss.termit.exception.UnsupportedOperationException; +import cz.cvut.kbss.termit.websocket.ResultWithHeaders; import org.jetbrains.annotations.NotNull; import org.springframework.core.MethodParameter; import org.springframework.messaging.Message; import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.messaging.support.MessageHeaderAccessor; - -import java.util.HashMap; -import java.util.Map; +import org.springframework.messaging.simp.annotation.support.MissingSessionUserException; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; public class WebSocketMessageWithHeadersValueHandler implements HandlerMethodReturnValueHandler { @@ -27,13 +27,18 @@ public boolean supportsReturnType(MethodParameter returnType) { @Override public void handleReturnValue(Object returnValue, @NotNull MethodParameter returnType, @NotNull Message message) throws Exception { - final MessageHeaderAccessor originalHeadersAccessor = MessageHeaderAccessor.getAccessor(message); - if (originalHeadersAccessor != null && returnValue instanceof ResultWithHeaders resultWithHeaders) { - final Map headers = new HashMap<>(); - headers.putAll(originalHeadersAccessor.toMap()); - headers.putAll(resultWithHeaders.headers()); - - simpMessagingTemplate.convertAndSend(resultWithHeaders.destination(), resultWithHeaders.payload(), headers); + if (returnValue instanceof ResultWithHeaders resultWithHeaders) { + final StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message); + resultWithHeaders.headers().forEach(headerAccessor::setNativeHeader); + if (resultWithHeaders.toUser()) { + final String sessionId = SimpMessageHeaderAccessor.getSessionId(headerAccessor.toMessageHeaders()); + if (sessionId == null || sessionId.isBlank()) { + throw new MissingSessionUserException(message); + } + simpMessagingTemplate.convertAndSendToUser(sessionId, resultWithHeaders.destination(), resultWithHeaders.payload(), headerAccessor.toMessageHeaders()); + } else { + simpMessagingTemplate.convertAndSend(resultWithHeaders.destination(), resultWithHeaders.payload(), headerAccessor.toMessageHeaders()); + } return; } throw new UnsupportedOperationException("Unable to process returned value: " + returnValue + " of type " + returnType.getParameterType() + " from " + returnType.getMethod()); From b693f4077ed10f24685614ef29e682bcfae90ca8 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 07:20:32 +0200 Subject: [PATCH 056/150] websocket integration test --- .../websocket/VocabularySocketController.java | 1 - ...etConfig.java => TestWebSocketConfig.java} | 10 +- .../html/HtmlTermOccurrenceResolverTest.java | 3 + .../BaseWebSocketControllerTestRunner.java | 6 +- .../BaseWebSocketIntegrationTestRunner.java | 169 ++++++++++++++++ .../IntegrationWebSocketSecurityTest.java | 185 ++++++++++++++++++ .../VocabularySocketControllerTest.java | 2 +- 7 files changed, 364 insertions(+), 12 deletions(-) rename src/test/java/cz/cvut/kbss/termit/environment/config/{TestWebsocketConfig.java => TestWebSocketConfig.java} (92%) create mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java create mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index cbab5b349..67c1af291 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -27,7 +27,6 @@ @MessageMapping("/vocabularies") @PreAuthorize("hasRole('" + SecurityConstants.ROLE_RESTRICTED_USER + "')") public class VocabularySocketController extends BaseController { - private static final Logger LOG = LoggerFactory.getLogger(VocabularySocketController.class); private final VocabularyService vocabularyService; diff --git a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebsocketConfig.java b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java similarity index 92% rename from src/test/java/cz/cvut/kbss/termit/environment/config/TestWebsocketConfig.java rename to src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java index 8ef28a0e1..f3cd62e7d 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebsocketConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java @@ -6,7 +6,6 @@ import cz.cvut.kbss.termit.websocket.util.ReturnValueCollectingSimpMessagingTemplate; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Lookup; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.ApplicationListener; @@ -20,7 +19,6 @@ import org.springframework.messaging.MessageHandler; import org.springframework.messaging.SubscribableChannel; import org.springframework.messaging.converter.CompositeMessageConverter; -import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; import org.springframework.messaging.simp.config.ChannelRegistration; @@ -37,7 +35,7 @@ @EnableConfigurationProperties(Configuration.class) @Import({TestSecurityConfig.class, TestRestSecurityConfig.class, WebAppConfig.class, WebSocketConfig.class}) @ComponentScan(basePackages = "cz.cvut.kbss.termit.websocket") -public class TestWebsocketConfig +public class TestWebSocketConfig implements ApplicationListener, WebSocketMessageBrokerConfigurer { private final List channels; @@ -46,14 +44,14 @@ public class TestWebsocketConfig @Autowired @Lazy - public TestWebsocketConfig(List channels, List handlers) { + public TestWebSocketConfig(List channels, List handlers) { this.channels = channels; this.handlers = handlers; } /** - * Unregisters MessageHandler's from the message channels to reduce processing during the test - * + * Unregisters MessageHandler's from the message channels to reduce processing during the test. + * Also stops further processing so for example user responses remain in the broker channel. * @param event the event to respond to */ @Override diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolverTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolverTest.java index 627a24feb..bf85ea85e 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolverTest.java @@ -32,6 +32,8 @@ import org.jsoup.Jsoup; import org.jsoup.select.Elements; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -177,6 +179,7 @@ void findTermOccurrencesMarksOccurrencesAsSuggested() { } @Test + @DisabledOnOs(OS.WINDOWS) // TODO: https://github.com/kbss-cvut/termit/issues/275 void findTermOccurrencesSetsFoundOccurrencesAsApprovedWhenCorrespondingExistingOccurrenceWasApproved() throws Exception { when(termService.exists(TERM_URI)).thenReturn(true); final File file = initFile(); diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java index 11b13c5df..4c4c944d8 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java @@ -1,10 +1,9 @@ package cz.cvut.kbss.termit.websocket; -import cz.cvut.kbss.termit.environment.config.TestWebsocketConfig; +import cz.cvut.kbss.termit.environment.config.TestWebSocketConfig; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.websocket.util.CachingChannelInterceptor; import jakarta.annotation.PostConstruct; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @@ -19,7 +18,6 @@ import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.event.annotation.BeforeTestClass; import org.springframework.test.context.junit.jupiter.SpringExtension; import java.util.Map; @@ -33,7 +31,7 @@ @ExtendWith(MockitoExtension.class) @EnableConfigurationProperties({Configuration.class}) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) -@ContextConfiguration(classes = {TestWebsocketConfig.class}, +@ContextConfiguration(classes = {TestWebSocketConfig.class}, initializers = {ConfigDataApplicationContextInitializer.class}) public abstract class BaseWebSocketControllerTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java new file mode 100644 index 000000000..59ba7d502 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java @@ -0,0 +1,169 @@ +package cz.cvut.kbss.termit.websocket; + +import cz.cvut.kbss.termit.config.AppConfig; +import cz.cvut.kbss.termit.config.SecurityConfig; +import cz.cvut.kbss.termit.config.WebAppConfig; +import cz.cvut.kbss.termit.config.WebSocketConfig; +import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.environment.config.TestConfig; +import cz.cvut.kbss.termit.environment.config.TestPersistenceConfig; +import cz.cvut.kbss.termit.environment.config.TestSecurityConfig; +import cz.cvut.kbss.termit.environment.config.TestServiceConfig; +import cz.cvut.kbss.termit.security.JwtUtils; +import cz.cvut.kbss.termit.security.model.TermItUserDetails; +import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; +import cz.cvut.kbss.termit.util.Configuration; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.context.annotation.aspectj.EnableSpringConfigured; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +@EnableSpringConfigured +@EnableAutoConfiguration +@EnableTransactionManagement +@ExtendWith(MockitoExtension.class) +@EnableAspectJAutoProxy(proxyTargetClass = true) +@EnableConfigurationProperties({Configuration.class}) +@ContextConfiguration( + classes = {TestConfig.class, TestPersistenceConfig.class, TestConfig.class, + TestServiceConfig.class, AppConfig.class, SecurityConfig.class, WebAppConfig.class, WebSocketConfig.class}, + initializers = {ConfigDataApplicationContextInitializer.class}) +@ComponentScan("cz.cvut.kbss.termit.security") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class BaseWebSocketIntegrationTestRunner { + + protected Logger LOG = LoggerFactory.getLogger(this.getClass()); + + protected WebSocketStompClient stompClient; + + @Value("ws://localhost:${local.server.port}/ws") + protected String url; + + @SpyBean + protected TermItUserDetailsService userDetailsService; + + @SpyBean + protected JwtUtils jwtUtils; + + protected TermItUserDetails userDetails; + + protected Future connect(StompSessionHandlerAdapter sessionHandler) { + return stompClient.connectAsync(url, sessionHandler); + } + + protected String generateToken() { + return jwtUtils.generateToken(userDetails.getUser(), userDetails.getAuthorities()); + } + + @BeforeEach + void runnerSetup() { + stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + + userDetails = new TermItUserDetails(Generator.generateUserAccountWithPassword()); + doReturn(userDetails).when(userDetailsService).loadUserByUsername(userDetails.getUsername()); + } + + protected class TestWebSocketSessionHandler implements WebSocketHandler { + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + LOG.info("WebSocket connection established"); + } + + @Override + public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { + LOG.info("WebSocket message received: {}", message.getPayload()); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + LOG.error("WebSocket transport error", exception); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { + LOG.info("WebSocket connection closed"); + } + + @Override + public boolean supportsPartialMessages() { + return false; + } + } + + protected class TestStompSessionHandler extends StompSessionHandlerAdapter { + + private final AtomicReference exception = new AtomicReference<>(); + + @Override + public void afterConnected(StompSession session, StompHeaders connectedHeaders) { + super.afterConnected(session, connectedHeaders); + LOG.info("STOMP session connected"); + } + + @Override + public void handleFrame(@NotNull StompHeaders headers, Object payload) { + super.handleFrame(headers, payload); + exception.set(new Exception(headers.toString())); + LOG.error("STOMP frame: {}", headers); + } + + @Override + public void handleException(@NotNull StompSession session, StompCommand command, @NotNull StompHeaders headers, + byte @NotNull [] payload, @NotNull Throwable exception) { + super.handleException(session, command, headers, payload, exception); + this.exception.set(exception); + LOG.error("STOMP exception", exception); + } + + @Override + public void handleTransportError(@NotNull StompSession session, @NotNull Throwable exception) { + super.handleTransportError(session, exception); + this.exception.set(exception); + LOG.error("STOMP transport error", exception); + } + + public Throwable getException() { + return exception.get(); + } + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java new file mode 100644 index 000000000..2e0b3423a --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java @@ -0,0 +1,185 @@ +package cz.cvut.kbss.termit.websocket; + +import com.fasterxml.jackson.databind.ObjectMapper; +import cz.cvut.kbss.termit.security.SecurityConstants; +import cz.cvut.kbss.termit.util.Utils; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.jackson.io.JacksonSerializer; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandler; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.WebSocketClient; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IntegrationWebSocketSecurityTest extends BaseWebSocketIntegrationTestRunner { + + @Autowired + ObjectMapper objectMapper; + + /** + * @return Stream of argument pairs with StompCommand (CONNECT excluded) and true + false value for each command + */ + public static Stream stompCommands() { + return Arrays.stream(StompCommand.values()).filter(c -> c != StompCommand.CONNECT).map(Enum::name) + .flatMap(name -> Stream.of(Arguments.of(name, true), Arguments.of(name, false))); + } + + /** + * Ensures that connection is closed on receiving any message other than CONNECT + * (even with valid authorization token) + */ + @ParameterizedTest + @MethodSource("stompCommands") + void connectionIsClosedOnAnyMessageBeforeConnect(String stompCommand, Boolean withAuth) throws Exception { + final AtomicBoolean receivedReply = new AtomicBoolean(false); + final AtomicBoolean receivedError = new AtomicBoolean(false); + + final String auth = withAuth ? HttpHeaders.AUTHORIZATION + ":" + SecurityConstants.JWT_TOKEN_PREFIX + generateToken() + "\n" : ""; + final TextMessage message = new TextMessage(stompCommand + "\n" + auth + "\n\0"); + + final WebSocketClient wsClient = new StandardWebSocketClient(); + Future connectFuture = wsClient.execute(makeWebSocketHandler(receivedReply, receivedError), url); + + WebSocketSession session = connectFuture.get(5, TimeUnit.SECONDS); + + assertTrue(session.isOpen()); + + session.sendMessage(message); + + await().atMost(5, TimeUnit.SECONDS).until(() -> !session.isOpen()); + + assertTrue(receivedError.get()); + assertFalse(session.isOpen()); + assertFalse(receivedReply.get()); + } + + WebSocketHandler makeWebSocketHandler(AtomicBoolean receivedReply, AtomicBoolean receivedError) { + return new TestWebSocketSessionHandler() { + @Override + public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { + super.handleMessage(session, message); + if (message instanceof TextMessage textMessage) { + final String command = textMessage.getPayload().split("\n")[0]; + if (command.equals(StompCommand.ERROR.name())) { + receivedError.set(true); + return; + } + } + receivedReply.set(true); + session.close(); + } + }; + } + + /** + * STOMP CONNECT message is rejected with invalid auth token + */ + @Test + void connectWithInvalidAuthorizationIsRejected() throws Throwable { + final AtomicBoolean receivedReply = new AtomicBoolean(false); + final AtomicBoolean receivedError = new AtomicBoolean(false); + + final TextMessage message = new TextMessage(StompCommand.CONNECT + "\n" + HttpHeaders.AUTHORIZATION + ":" + SecurityConstants.JWT_TOKEN_PREFIX + "DefinitelyNotValidToken\n\n\0"); + + final WebSocketClient wsClient = new StandardWebSocketClient(); + Future connectFuture = wsClient.execute(makeWebSocketHandler(receivedReply, receivedError), url); + + WebSocketSession session = connectFuture.get(5, TimeUnit.SECONDS); + + assertTrue(session.isOpen()); + + session.sendMessage(message); + + await().atMost(5, TimeUnit.SECONDS).until(() -> !session.isOpen()); + + assertTrue(receivedError.get()); + assertFalse(session.isOpen()); + assertFalse(receivedReply.get()); + } + + /** + * STOMP CONNECT message is rejected with invalid JWT token + */ + @Test + void connectWithInvalidJwtAuthorizationIsRejected() throws Throwable { + final AtomicBoolean receivedReply = new AtomicBoolean(false); + final AtomicBoolean receivedError = new AtomicBoolean(false); + + final Instant issued = Utils.timestamp(); + // creates "valid" JWT token but with invalid signature + final String token = Jwts.builder().setSubject(userDetails.getUser().getUsername()) + .setId(userDetails.getUser().getUri().toString()).setIssuedAt(Date.from(issued)) + .setExpiration(Date.from(issued.plusMillis(SecurityConstants.SESSION_TIMEOUT))) + .signWith(Keys.hmacShaKeyFor("my very secure and really private key".getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) + .serializeToJsonWith(new JacksonSerializer<>(objectMapper)).compact(); + + final TextMessage message = new TextMessage(StompCommand.CONNECT + "\n" + HttpHeaders.AUTHORIZATION + ":" + SecurityConstants.JWT_TOKEN_PREFIX + token + "\n\n\0"); + + final WebSocketClient wsClient = new StandardWebSocketClient(); + Future connectFuture = wsClient.execute(makeWebSocketHandler(receivedReply, receivedError), url); + + WebSocketSession session = connectFuture.get(5, TimeUnit.SECONDS); + + assertTrue(session.isOpen()); + + session.sendMessage(message); + + await().atMost(5, TimeUnit.SECONDS).until(() -> !session.isOpen()); + + assertTrue(receivedError.get()); + assertFalse(session.isOpen()); + assertFalse(receivedReply.get()); + } + + /** + * Checks that it is possible to establish STOMP connection with valid authorization + */ + @Test + void connectionIsNotClosedWhenConnectMessageIsSent() throws Throwable { + final AtomicBoolean stompConnected = new AtomicBoolean(false); + final StompSessionHandler handler = new TestStompSessionHandler() { + @Override + public void afterConnected(StompSession session, StompHeaders connectedHeaders) { + super.afterConnected(session, connectedHeaders); + stompConnected.set(session.isConnected()); + } + }; + + final StompHeaders headers = new StompHeaders(); + headers.add(HttpHeaders.AUTHORIZATION, SecurityConstants.JWT_TOKEN_PREFIX + generateToken()); + + Future connectFuture = stompClient.connectAsync(URI.create(url), null, headers, handler); + + StompSession session = connectFuture.get(5, TimeUnit.SECONDS); + assertTrue(session.isConnected()); + assertTrue(stompConnected.get()); + session.disconnect(); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java index 8e49c37c6..65507b909 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java @@ -96,7 +96,7 @@ void validateVocabularyReturnsValidationResults() { assertNotNull(reply); StompHeaderAccessor replyHeaders = StompHeaderAccessor.wrap(reply); // as reply is sent to a common channel for all vocabularies, there must be header with vocabulary uri - assertEquals(vocabulary.getUri(), replyHeaders.getHeader("vocabulary"), "Invalid or missing vocabulary header in the reply"); + assertEquals(vocabulary.getUri().toString(), replyHeaders.getFirstNativeHeader("vocabulary"), "Invalid or missing vocabulary header in the reply"); Optional> payload = readPayload(reply); assertTrue(payload.isPresent()); From 88dda5b2c5b3560e05155e22d93c5d7830dd0bd2 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 08:00:15 +0200 Subject: [PATCH 057/150] fix websocket integration test --- .../IntegrationWebSocketSecurityTest.java | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java index 2e0b3423a..b3bbcc043 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java @@ -40,6 +40,11 @@ class IntegrationWebSocketSecurityTest extends BaseWebSocketIntegrationTestRunner { + /** + * The number of seconds after which some operations will time out. + */ + private static final int OPERATION_TIMEOUT = 15; + @Autowired ObjectMapper objectMapper; @@ -67,13 +72,13 @@ void connectionIsClosedOnAnyMessageBeforeConnect(String stompCommand, Boolean wi final WebSocketClient wsClient = new StandardWebSocketClient(); Future connectFuture = wsClient.execute(makeWebSocketHandler(receivedReply, receivedError), url); - WebSocketSession session = connectFuture.get(5, TimeUnit.SECONDS); + WebSocketSession session = connectFuture.get(OPERATION_TIMEOUT, TimeUnit.SECONDS); assertTrue(session.isOpen()); session.sendMessage(message); - await().atMost(5, TimeUnit.SECONDS).until(() -> !session.isOpen()); + await().atMost(OPERATION_TIMEOUT, TimeUnit.SECONDS).until(() -> !session.isOpen()); assertTrue(receivedError.get()); assertFalse(session.isOpen()); @@ -111,13 +116,13 @@ void connectWithInvalidAuthorizationIsRejected() throws Throwable { final WebSocketClient wsClient = new StandardWebSocketClient(); Future connectFuture = wsClient.execute(makeWebSocketHandler(receivedReply, receivedError), url); - WebSocketSession session = connectFuture.get(5, TimeUnit.SECONDS); + WebSocketSession session = connectFuture.get(OPERATION_TIMEOUT, TimeUnit.SECONDS); assertTrue(session.isOpen()); session.sendMessage(message); - await().atMost(5, TimeUnit.SECONDS).until(() -> !session.isOpen()); + await().atMost(OPERATION_TIMEOUT, TimeUnit.SECONDS).until(() -> !session.isOpen()); assertTrue(receivedError.get()); assertFalse(session.isOpen()); @@ -145,13 +150,13 @@ void connectWithInvalidJwtAuthorizationIsRejected() throws Throwable { final WebSocketClient wsClient = new StandardWebSocketClient(); Future connectFuture = wsClient.execute(makeWebSocketHandler(receivedReply, receivedError), url); - WebSocketSession session = connectFuture.get(5, TimeUnit.SECONDS); + WebSocketSession session = connectFuture.get(OPERATION_TIMEOUT, TimeUnit.SECONDS); assertTrue(session.isOpen()); session.sendMessage(message); - await().atMost(5, TimeUnit.SECONDS).until(() -> !session.isOpen()); + await().atMost(OPERATION_TIMEOUT, TimeUnit.SECONDS).until(() -> !session.isOpen()); assertTrue(receivedError.get()); assertFalse(session.isOpen()); @@ -163,23 +168,16 @@ void connectWithInvalidJwtAuthorizationIsRejected() throws Throwable { */ @Test void connectionIsNotClosedWhenConnectMessageIsSent() throws Throwable { - final AtomicBoolean stompConnected = new AtomicBoolean(false); - final StompSessionHandler handler = new TestStompSessionHandler() { - @Override - public void afterConnected(StompSession session, StompHeaders connectedHeaders) { - super.afterConnected(session, connectedHeaders); - stompConnected.set(session.isConnected()); - } - }; + final StompSessionHandler handler = new TestStompSessionHandler(); final StompHeaders headers = new StompHeaders(); headers.add(HttpHeaders.AUTHORIZATION, SecurityConstants.JWT_TOKEN_PREFIX + generateToken()); Future connectFuture = stompClient.connectAsync(URI.create(url), null, headers, handler); - StompSession session = connectFuture.get(5, TimeUnit.SECONDS); + StompSession session = connectFuture.get(OPERATION_TIMEOUT, TimeUnit.SECONDS); assertTrue(session.isConnected()); - assertTrue(stompConnected.get()); session.disconnect(); + await().atMost(OPERATION_TIMEOUT, TimeUnit.SECONDS).until(() -> !session.isConnected()); } } From 4cf5fddc8afe4ffc9a264773eb4b89ad21198ba8 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 09:42:04 +0200 Subject: [PATCH 058/150] fix exception handling --- .../kbss/termit/config/SecurityConfig.java | 7 +- .../kbss/termit/config/WebSocketConfig.java | 5 +- .../rest/handler/RestExceptionHandler.java | 15 +- .../handler/WebSocketExceptionHandler.java | 214 ++++++++++-------- .../WebSocketExceptionHandlerTest.java | 59 +++++ 5 files changed, 191 insertions(+), 109 deletions(-) create mode 100644 src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java diff --git a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java index d9ff49012..6e17751a6 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java @@ -47,6 +47,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @@ -153,6 +154,10 @@ protected static CorsConfigurationSource createCorsConfiguration( return source; } + /** + * Part of {@link EnableWebSocketSecurity @EnableWebSocketSecurity} replacement + * @see WebSocketConfig + */ @Bean @Scope("prototype") public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder( @@ -164,7 +169,7 @@ public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorization } /** - * WebSocket authorization + * WebSocket endpoint authorization */ @Bean public AuthorizationManager> messageAuthorizationManager( diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java index 6c42b5ce2..32f8e5e4d 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.core.Ordered; @@ -118,13 +119,15 @@ public void configureWebSocketTransport(WebSocketTransportRegistration registry) public boolean configureMessageConverters(List messageConverters) { messageConverters.add(termitJsonLdMessageConverter()); messageConverters.add(termitStringMessageConverter()); - return true; + return false; // do not add default converters } + @Bean public MessageConverter termitStringMessageConverter() { return new StringMessageConverter(StandardCharsets.UTF_8); } + @Bean public MessageConverter termitJsonLdMessageConverter() { return new MappingJackson2MessageConverter(jsonLdMapper); } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index bf1a88cd9..c17a253d1 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -39,7 +39,6 @@ import cz.cvut.kbss.termit.exception.WebServiceIntegrationException; import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; -import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -49,6 +48,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import jakarta.servlet.http.HttpServletRequest; + /** * Exception handlers for REST controllers. *

@@ -59,28 +60,28 @@ @RestControllerAdvice public class RestExceptionHandler { - private final Logger LOG = LoggerFactory.getLogger(this.getClass()); + private static final Logger LOG = LoggerFactory.getLogger(RestExceptionHandler.class); - private void logException(TermItException ex, HttpServletRequest request) { + private static void logException(TermItException ex, HttpServletRequest request) { if (shouldSuppressLogging(ex)) { return; } logException("Exception caught when processing request to '" + request.getRequestURI() + "'.", ex); } - private boolean shouldSuppressLogging(TermItException ex) { + private static boolean shouldSuppressLogging(TermItException ex) { return ex.getClass().getAnnotation(SuppressibleLogging.class) != null; } - private void logException(Throwable ex, HttpServletRequest request) { + private static void logException(Throwable ex, HttpServletRequest request) { logException("Exception caught when processing request to '" + request.getRequestURI() + "'.", ex); } - private void logException(String message, Throwable ex) { + private static void logException(String message, Throwable ex) { LOG.error(message, ex); } - private ErrorInfo errorInfo(HttpServletRequest request, Throwable e) { + private static ErrorInfo errorInfo(HttpServletRequest request, Throwable e) { return ErrorInfo.createWithMessage(e.getMessage(), request.getRequestURI()); } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index 69cdceb2c..b5b19d9d0 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -14,6 +14,7 @@ import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.exception.ResourceExistsException; import cz.cvut.kbss.termit.exception.SnapshotNotEditableException; +import cz.cvut.kbss.termit.exception.SuppressibleLogging; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.UnsupportedOperationException; import cz.cvut.kbss.termit.exception.UnsupportedSearchFacetException; @@ -22,178 +23,191 @@ import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; import cz.cvut.kbss.termit.rest.handler.ErrorInfo; -import cz.cvut.kbss.termit.rest.handler.RestExceptionHandler; -import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.ResponseEntity; import org.springframework.messaging.Message; import org.springframework.messaging.MessageDeliveryException; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.simp.annotation.SendToUser; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Component; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; -@Component +@SendToUser @ControllerAdvice -public class WebSocketExceptionHandler extends RestExceptionHandler { +public class WebSocketExceptionHandler { private static final Logger LOG = LoggerFactory.getLogger(WebSocketExceptionHandler.class); + private static String destination(Message message) { + return message.getHeaders().getOrDefault("destination", "missing destination").toString(); + } + + private static void logException(TermItException ex, Message message) { + if (shouldSuppressLogging(ex)) { + return; + } + logException("Exception caught when processing request to '" + destination(message) + "'.", ex); + } + + private static boolean shouldSuppressLogging(TermItException ex) { + return ex.getClass().getAnnotation(SuppressibleLogging.class) != null; + } + + private static void logException(Throwable ex, Message message) { + logException("Exception caught when processing request to '" + destination(message) + "'.", ex); + } + + private static void logException(String message, Throwable ex) { + LOG.error(message, ex); + } + + private static ErrorInfo errorInfo(Message message, Throwable e) { + return ErrorInfo.createWithMessage(e.getMessage(), destination(message)); + } + @MessageExceptionHandler public void messageDeliveryException(Message message, MessageDeliveryException e) { final StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message); LOG.error("Failed to send message with destination {}: {}", headerAccessor.getDestination(), e.getMessage()); } - @Override - @MessageExceptionHandler - public ResponseEntity persistenceException(HttpServletRequest request, PersistenceException e) { - return super.persistenceException(request, e); + @MessageExceptionHandler(PersistenceException.class) + public ErrorInfo persistenceException(Message message, PersistenceException e) { + logException(e, message); + return errorInfo(message, e.getCause()); } - @Override - @MessageExceptionHandler - public ResponseEntity jopaException(HttpServletRequest request, OWLPersistenceException e) { - return super.jopaException(request, e); + @MessageExceptionHandler(OWLPersistenceException.class) + public ErrorInfo jopaException(Message message, OWLPersistenceException e) { + logException("Persistence exception caught.", e); + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity resourceExistsException(HttpServletRequest request, ResourceExistsException e) { - return super.resourceExistsException(request, e); + @MessageExceptionHandler(ResourceExistsException.class) + public ErrorInfo resourceExistsException(Message message, ResourceExistsException e) { + logException(e, message); + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity resourceNotFound(HttpServletRequest request, NotFoundException e) { - return super.resourceNotFound(request, e); + @MessageExceptionHandler(NotFoundException.class) + public ErrorInfo resourceNotFound(Message message, NotFoundException e) { + // Not necessary to log NotFoundException, they may be quite frequent and do not represent an issue with the application + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity usernameNotFound(HttpServletRequest request, UsernameNotFoundException e) { - return super.usernameNotFound(request, e); + @MessageExceptionHandler(UsernameNotFoundException.class) + public ErrorInfo usernameNotFound(Message message, UsernameNotFoundException e) { + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity entityNotFoundException(HttpServletRequest request, EntityNotFoundException e) { - return super.entityNotFoundException(request, e); + @MessageExceptionHandler(EntityNotFoundException.class) + public ErrorInfo entityNotFoundException(Message message, EntityNotFoundException e) { + logException(e, message); + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity authorizationException(HttpServletRequest request, AuthorizationException e) { - return super.authorizationException(request, e); + @MessageExceptionHandler(AuthorizationException.class) + public ErrorInfo authorizationException(Message message, AuthorizationException e) { + logException(e, message); + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity validationException(HttpServletRequest request, ValidationException e) { - return super.validationException(request, e); + @MessageExceptionHandler(ValidationException.class) + public ErrorInfo validationException(Message message, ValidationException e) { + logException(e, message); + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity webServiceIntegrationException(HttpServletRequest request, - WebServiceIntegrationException e) { - return super.webServiceIntegrationException(request, e); + @MessageExceptionHandler(WebServiceIntegrationException.class) + public ErrorInfo webServiceIntegrationException(Message message, WebServiceIntegrationException e) { + logException(e.getCause(), message); + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity annotationGenerationException(HttpServletRequest request, - AnnotationGenerationException e) { - return super.annotationGenerationException(request, e); + @MessageExceptionHandler(AnnotationGenerationException.class) + public ErrorInfo annotationGenerationException(Message message, AnnotationGenerationException e) { + logException(e.getCause(), message); + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity termItException(HttpServletRequest request, TermItException e) { - return super.termItException(request, e); + @MessageExceptionHandler(TermItException.class) + public ErrorInfo termItException(Message message, TermItException e) { + logException(e, message); + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity jsonLdException(HttpServletRequest request, JsonLdException e) { - return super.jsonLdException(request, e); + @MessageExceptionHandler(JsonLdException.class) + public ErrorInfo jsonLdException(Message message, JsonLdException e) { + logException(e, message); + return ErrorInfo.createWithMessage("Error when processing JSON-LD.", destination(message)); } - @Override - @MessageExceptionHandler - public ResponseEntity unsupportedAssetOperationException(HttpServletRequest request, - UnsupportedOperationException e) { - return super.unsupportedAssetOperationException(request, e); + @MessageExceptionHandler(UnsupportedOperationException.class) + public ErrorInfo unsupportedAssetOperationException(Message message, UnsupportedOperationException e) { + logException(e, message); + return errorInfo(message, e); } - @Override - @MessageExceptionHandler - public ResponseEntity vocabularyImportException(HttpServletRequest request, - VocabularyImportException e) { - return super.vocabularyImportException(request, e); + @MessageExceptionHandler(VocabularyImportException.class) + public ErrorInfo vocabularyImportException(Message message, VocabularyImportException e) { + logException(e, message); + return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), destination(message)); } - @Override @MessageExceptionHandler - public ResponseEntity unsupportedImportMediaTypeException(HttpServletRequest request, - UnsupportedImportMediaTypeException e) { - return super.unsupportedImportMediaTypeException(request, e); + public ErrorInfo unsupportedImportMediaTypeException(Message message, UnsupportedImportMediaTypeException e) { + logException(e, message); + return errorInfo(message, e); } - @Override @MessageExceptionHandler - public ResponseEntity assetRemovalException(HttpServletRequest request, AssetRemovalException e) { - return super.assetRemovalException(request, e); + public ErrorInfo assetRemovalException(Message message, AssetRemovalException e) { + logException(e, message); + return errorInfo(message, e); } - @Override @MessageExceptionHandler - public ResponseEntity invalidParameter(HttpServletRequest request, InvalidParameterException e) { - return super.invalidParameter(request, e); + public ErrorInfo invalidParameter(Message message, InvalidParameterException e) { + logException(e, message); + return errorInfo(message, e); } - @Override @MessageExceptionHandler - public ResponseEntity maxUploadSizeExceededException(HttpServletRequest request, - MaxUploadSizeExceededException e) { - return super.maxUploadSizeExceededException(request, e); + public ErrorInfo maxUploadSizeExceededException(Message message, MaxUploadSizeExceededException e) { + logException(e, message); + return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), "error.file.maxUploadSizeExceeded", destination(message)); } - @Override @MessageExceptionHandler - public ResponseEntity snapshotNotEditableException(HttpServletRequest request, - SnapshotNotEditableException e) { - return super.snapshotNotEditableException(request, e); + public ErrorInfo snapshotNotEditableException(Message message, SnapshotNotEditableException e) { + logException(e, message); + return ErrorInfo.createWithMessage(e.getMessage(), destination(message)); } - @Override @MessageExceptionHandler - public ResponseEntity unsupportedSearchFacetException(HttpServletRequest request, - UnsupportedSearchFacetException e) { - return super.unsupportedSearchFacetException(request, e); + public ErrorInfo unsupportedSearchFacetException(Message message, UnsupportedSearchFacetException e) { + logException(e, message); + return errorInfo(message, e); } - @Override @MessageExceptionHandler - public ResponseEntity invalidLanguageConstantException(HttpServletRequest request, - InvalidLanguageConstantException e) { - return super.invalidLanguageConstantException(request, e); + public ErrorInfo invalidLanguageConstantException(Message message, InvalidLanguageConstantException e) { + logException(e, message); + return errorInfo(message, e); } - @Override @MessageExceptionHandler - public ResponseEntity invalidTermStateException(HttpServletRequest request, - InvalidTermStateException e) { - return super.invalidTermStateException(request, e); + public ErrorInfo invalidTermStateException(Message message, InvalidTermStateException e) { + logException(e, message); + return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), destination(message)); } - @Override @MessageExceptionHandler - public ResponseEntity invalidPasswordChangeRequestException(HttpServletRequest request, - InvalidPasswordChangeRequestException e) { - return super.invalidPasswordChangeRequestException(request, e); + public ErrorInfo invalidPasswordChangeRequestException(Message message, + InvalidPasswordChangeRequestException e) { + logException(e, message); + return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), destination(message)); } } diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java new file mode 100644 index 000000000..52df68045 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java @@ -0,0 +1,59 @@ +package cz.cvut.kbss.termit.websocket; + +import cz.cvut.kbss.termit.environment.Environment; +import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.exception.PersistenceException; +import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.security.core.Authentication; + +import java.util.HashMap; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WebSocketExceptionHandlerTest extends BaseWebSocketControllerTestRunner { + + @SpyBean + WebSocketExceptionHandler sut; + + @MockBean + VocabularySocketController controller; + + StompHeaderAccessor messageHeaders; + + @BeforeEach + public void setup() { + messageHeaders = StompHeaderAccessor.create(StompCommand.MESSAGE); + messageHeaders.setSessionId("0"); + messageHeaders.setSubscriptionId("0"); + Authentication auth = Environment.setCurrentUser(Generator.generateUserAccountWithPassword()); + messageHeaders.setUser(auth); + messageHeaders.setSessionAttributes(new HashMap<>()); + messageHeaders.setContentLength(0); + messageHeaders.setHeader("namespace", "namespace"); + messageHeaders.setDestination("/vocabularies/fragment/validate"); + } + + void sendMessage() { + this.serverInboundChannel.send(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); + } + + @Test + void handlerIsCalledForPersistenceException() { + final PersistenceException e = new PersistenceException(new Exception("mocked exception")); + when(controller.validateVocabulary(any(), any())).thenThrow(e); + sendMessage(); + verify(sut).persistenceException(notNull(), eq(e)); + } + +} From 932506d294a6a3c1541da8c7e4ba2e70b87bf11f Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 10:14:04 +0200 Subject: [PATCH 059/150] docs --- .../security/WebSocketJwtAuthorizationInterceptor.java | 5 +++++ .../util/ReturnValueCollectingSimpMessagingTemplate.java | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java index 3acfc875e..71e5627de 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java +++ b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java @@ -18,6 +18,11 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UsernameNotFoundException; +/** + * Authorizes STOMP CONNECT messages + *

+ * Retrieves token from the {@code Authorization} header of STOMP message and validates JWT token. + */ public class WebSocketJwtAuthorizationInterceptor implements ChannelInterceptor { private final JwtUtils jwtUtils; diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java index 22be11a58..6a9dd91a5 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java @@ -10,6 +10,11 @@ import java.util.Map; import java.util.UUID; +/** + * Intercepts doConvert method and caches the returned payload before conversion + * mapped by resulting message id. + * Allows reading raw-returned values before serialization. + */ public class ReturnValueCollectingSimpMessagingTemplate extends SimpMessagingTemplate { public static final String MESSAGE_IDENTIFIER_HEADER = "test-message-id"; From fc978bcbe55f55094f5a43a818dc74c5ef8c8ea4 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 13:44:05 +0200 Subject: [PATCH 060/150] update javadoc --- .../cvut/kbss/termit/websocket/ResultWithHeaders.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java b/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java index c89285c46..1ec5c0ef6 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java @@ -1,9 +1,11 @@ package cz.cvut.kbss.termit.websocket; import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; +import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.annotation.SendToUser; import java.util.Collections; import java.util.HashMap; @@ -14,12 +16,19 @@ * including the {@link #payload}, {@link #destination} and {@link #headers} for the resulting message. *

* Do not combine with other method-return-value handlers (like {@link SendTo @SendTo}) + *

+ * The {@code ResultWithHeaders} is then handled by {@link WebSocketMessageWithHeadersValueHandler}. + * Every value returned from a controller method + * can be handled only by a single {@link HandlerMethodReturnValueHandler}. + * Annotations like {@link SendTo @SendTo}/{@link SendToUser @SendToUser} + * are handled by separate return value handlers, so only one can be used simultaneously. * * @param payload The actual result of the method * @param destination The destination channel where the message will be sent * @param headers Headers that will overwrite headers in the message. * @param The type of the payload - * @see WebSocketMessageWithHeadersValueHandler processes results from methods + * @see WebSocketMessageWithHeadersValueHandler + * @see HandlerMethodReturnValueHandler */ public record ResultWithHeaders(T payload, @NotNull String destination, @NotNull Map headers, boolean toUser) { From 62f3286c60e16ef58a969d9b40f37125c88f11bb Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 13:54:57 +0200 Subject: [PATCH 061/150] update javadoc --- .../java/cz/cvut/kbss/termit/config/WebSocketConfig.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java index 32f8e5e4d..a0ad16d27 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java @@ -25,6 +25,7 @@ import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.authorization.SpringAuthorizationEventPublisher; +import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; import org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor; import org.springframework.security.messaging.context.AuthenticationPrincipalArgumentResolver; import org.springframework.security.messaging.context.SecurityContextChannelInterceptor; @@ -72,8 +73,9 @@ public WebSocketConfig(cz.cvut.kbss.termit.util.Configuration configuration, App this.simpMessagingTemplate = simpMessagingTemplate; } - /* WebSocket security setup (replaces @EnableWebSocketSecurity) */ - + /** + * WebSocket security setup (replaces {@link EnableWebSocketSecurity @EnableWebSocketSecurity}) + */ @Override public void addArgumentResolvers(List argumentResolvers) { AuthenticationPrincipalArgumentResolver resolver = new AuthenticationPrincipalArgumentResolver(); @@ -81,6 +83,7 @@ public void addArgumentResolvers(List argumentRes } /** + * WebSocket security setup (replaces {@link EnableWebSocketSecurity @EnableWebSocketSecurity}) * @see Spring security source */ @Override @@ -95,8 +98,6 @@ public void addReturnValueHandlers(List returnV returnValueHandlers.add(new WebSocketMessageWithHeadersValueHandler(simpMessagingTemplate)); } - /* WebSocket endpoint configuration */ - @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").setAllowedOrigins(configuration.getCors().getAllowedOrigins().split(",")); From c47785c92efd614246a05aa5c0fc18b274805449 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 14:01:27 +0200 Subject: [PATCH 062/150] add implSpec for exception handlers --- .../cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java | 1 + .../termit/websocket/handler/WebSocketExceptionHandler.java | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index c17a253d1..90eb63cac 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -56,6 +56,7 @@ * The general pattern should be that unless an exception can be handled in a more appropriate place it bubbles up to a * REST controller which originally received the request. There, it is caught by this handler, logged and a reasonable * error message is returned to the user. + * @implSpec Should reflect {@link cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler} */ @RestControllerAdvice public class RestExceptionHandler { diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index b5b19d9d0..50411c650 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -34,6 +34,9 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; +/** + * @implSpec Should reflect {@link cz.cvut.kbss.termit.rest.handler.RestExceptionHandler} + */ @SendToUser @ControllerAdvice public class WebSocketExceptionHandler { From 6e86878e4702c5cbf89d448e1755072fd7e9da61 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Tue, 3 Sep 2024 15:21:35 +0200 Subject: [PATCH 063/150] [Fix] Fix application not starting in OIDC mode due to wrong WebSocket configuration. --- pom.xml | 2 +- .../termit/config/OAuth2SecurityConfig.java | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 62ee8fd44..ab4877fd8 100644 --- a/pom.xml +++ b/pom.xml @@ -31,7 +31,7 @@ 2.7.0 1.6.0 2.6.0 - 2.0.4 + 2.0.5 0.15.0 diff --git a/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java index cc5184416..ed5d66bde 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java @@ -19,27 +19,39 @@ import cz.cvut.kbss.termit.security.AuthenticationSuccess; import cz.cvut.kbss.termit.security.HierarchicalRoleBasedAuthorityMapper; +import cz.cvut.kbss.termit.security.JwtUtils; import cz.cvut.kbss.termit.security.SecurityConstants; +import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; +import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import cz.cvut.kbss.termit.util.oidc.OidcGrantedAuthoritiesExtractor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; import org.springframework.core.convert.converter.Converter; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; +import org.springframework.util.AntPathMatcher; import org.springframework.web.cors.CorsConfigurationSource; import java.util.Collection; @@ -96,4 +108,35 @@ private Converter grantedAuthoritiesExtractor( new HierarchicalRoleBasedAuthorityMapper().mapAuthorities(authorities)); }; } + + /** + * Part of {@link EnableWebSocketSecurity @EnableWebSocketSecurity} replacement + * + * @see WebSocketConfig + */ + @Bean + @Scope("prototype") + public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder( + ApplicationContext context) { + return MessageMatcherDelegatingAuthorizationManager.builder().simpDestPathMatcher( + () -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) + ? context.getBean(SimpAnnotationMethodMessageHandler.class).getPathMatcher() + : new AntPathMatcher()); + } + + /** + * WebSocket endpoint authorization + */ + @Bean + public AuthorizationManager> messageAuthorizationManager( + MessageMatcherDelegatingAuthorizationManager.Builder messages) { + return messages.simpTypeMatchers(SimpMessageType.DISCONNECT).permitAll() + .anyMessage().authenticated().build(); + } + + @Bean + public WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor(JwtUtils jwtUtils, + TermItUserDetailsService userDetailsService) { + return new WebSocketJwtAuthorizationInterceptor(jwtUtils, userDetailsService); + } } From a300a10ea883d0746a97552e1911d3fcfafbbf01 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 14:47:57 +0200 Subject: [PATCH 064/150] move WS dependencies from security config --- .../kbss/termit/config/SecurityConfig.java | 39 -------------- .../kbss/termit/config/WebSocketConfig.java | 53 +++++++++++++++---- 2 files changed, 42 insertions(+), 50 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java index 6e17751a6..85904dce6 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java @@ -23,36 +23,26 @@ import cz.cvut.kbss.termit.security.JwtAuthorizationFilter; import cz.cvut.kbss.termit.security.JwtUtils; import cz.cvut.kbss.termit.security.SecurityConstants; -import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import cz.cvut.kbss.termit.util.Constants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Scope; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.messaging.Message; -import org.springframework.messaging.simp.SimpMessageType; -import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; -import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.HttpStatusEntryPoint; -import org.springframework.util.AntPathMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -153,33 +143,4 @@ protected static CorsConfigurationSource createCorsConfiguration( source.registerCorsConfiguration("/**", corsConfiguration); return source; } - - /** - * Part of {@link EnableWebSocketSecurity @EnableWebSocketSecurity} replacement - * @see WebSocketConfig - */ - @Bean - @Scope("prototype") - public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder( - ApplicationContext context) { - return MessageMatcherDelegatingAuthorizationManager.builder().simpDestPathMatcher( - () -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) - ? context.getBean(SimpAnnotationMethodMessageHandler.class).getPathMatcher() - : new AntPathMatcher()); - } - - /** - * WebSocket endpoint authorization - */ - @Bean - public AuthorizationManager> messageAuthorizationManager( - MessageMatcherDelegatingAuthorizationManager.Builder messages) { - return messages.simpTypeMatchers(SimpMessageType.DISCONNECT).permitAll() - .anyMessage().authenticated().build(); - } - - @Bean - public WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor() { - return new WebSocketJwtAuthorizationInterceptor(jwtUtils, userDetailsService); - } } diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java index a0ad16d27..c177726da 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java @@ -1,7 +1,9 @@ package cz.cvut.kbss.termit.config; import com.fasterxml.jackson.databind.ObjectMapper; +import cz.cvut.kbss.termit.security.JwtUtils; import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; +import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; @@ -12,6 +14,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Scope; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.messaging.Message; @@ -20,15 +23,19 @@ import org.springframework.messaging.converter.StringMessageConverter; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; +import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.authorization.SpringAuthorizationEventPublisher; import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; import org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; import org.springframework.security.messaging.context.AuthenticationPrincipalArgumentResolver; import org.springframework.security.messaging.context.SecurityContextChannelInterceptor; +import org.springframework.util.AntPathMatcher; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @@ -51,26 +58,25 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final ApplicationContext context; - private final AuthorizationManager> messageAuthorizationManager; - - private final WebSocketJwtAuthorizationInterceptor jwtAuthorizationInterceptor; - private final ObjectMapper jsonLdMapper; private final SimpMessagingTemplate simpMessagingTemplate; + private final JwtUtils jwtUtils; + + private final TermItUserDetailsService userDetailsService; + @Autowired public WebSocketConfig(cz.cvut.kbss.termit.util.Configuration configuration, ApplicationContext context, - AuthorizationManager> messageAuthorizationManager, - WebSocketJwtAuthorizationInterceptor jwtAuthorizationInterceptor, @Qualifier("jsonLdMapper") ObjectMapper jsonLdMapper, - @Lazy SimpMessagingTemplate simpMessagingTemplate) { + @Lazy SimpMessagingTemplate simpMessagingTemplate, JwtUtils jwtUtils, + TermItUserDetailsService userDetailsService) { this.configuration = configuration; this.context = context; - this.messageAuthorizationManager = messageAuthorizationManager; - this.jwtAuthorizationInterceptor = jwtAuthorizationInterceptor; this.jsonLdMapper = jsonLdMapper; this.simpMessagingTemplate = simpMessagingTemplate; + this.jwtUtils = jwtUtils; + this.userDetailsService = userDetailsService; } /** @@ -88,9 +94,9 @@ public void addArgumentResolvers(List argumentRes */ @Override public void configureClientInboundChannel(@NotNull ChannelRegistration registration) { - AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(this.messageAuthorizationManager); + AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(messageAuthorizationManager()); interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context)); - registration.interceptors(jwtAuthorizationInterceptor, new SecurityContextChannelInterceptor(), interceptor); + registration.interceptors(webSocketJwtAuthorizationInterceptor(), new SecurityContextChannelInterceptor(), interceptor); } @Override @@ -133,4 +139,29 @@ public MessageConverter termitJsonLdMessageConverter() { return new MappingJackson2MessageConverter(jsonLdMapper); } + /** + * WebSocket security setup (replaces {@link EnableWebSocketSecurity @EnableWebSocketSecurity}) + */ + @Bean + @Scope("prototype") + public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder() { + return MessageMatcherDelegatingAuthorizationManager.builder().simpDestPathMatcher( + () -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) + ? context.getBean(SimpAnnotationMethodMessageHandler.class).getPathMatcher() + : new AntPathMatcher()); + } + + /** + * WebSocket endpoint authorization + */ + @Bean + public AuthorizationManager> messageAuthorizationManager() { + return messageAuthorizationManagerBuilder().simpTypeMatchers(SimpMessageType.DISCONNECT).permitAll() + .anyMessage().authenticated().build(); + } + + @Bean + public WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor() { + return new WebSocketJwtAuthorizationInterceptor(jwtUtils, userDetailsService); + } } From b357fe169747335733ae7b51fe64ee25eee3c1b3 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 18:25:05 +0200 Subject: [PATCH 065/150] rework WebSocket configuration & authentication --- .../termit/config/OAuth2SecurityConfig.java | 50 +------- .../kbss/termit/config/SecurityConfig.java | 7 + .../kbss/termit/config/WebSocketConfig.java | 113 ++-------------- .../config/WebSocketMessageBrokerConfig.java | 121 ++++++++++++++++++ .../WebSocketJwtAuthorizationInterceptor.java | 81 +++++++----- 5 files changed, 196 insertions(+), 176 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java diff --git a/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java index ed5d66bde..32623b9a2 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java @@ -19,39 +19,29 @@ import cz.cvut.kbss.termit.security.AuthenticationSuccess; import cz.cvut.kbss.termit.security.HierarchicalRoleBasedAuthorityMapper; -import cz.cvut.kbss.termit.security.JwtUtils; import cz.cvut.kbss.termit.security.SecurityConstants; -import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; -import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import cz.cvut.kbss.termit.util.oidc.OidcGrantedAuthoritiesExtractor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Scope; import org.springframework.core.convert.converter.Converter; -import org.springframework.messaging.Message; -import org.springframework.messaging.simp.SimpMessageType; -import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.session.SessionRegistryImpl; -import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; -import org.springframework.util.AntPathMatcher; import org.springframework.web.cors.CorsConfigurationSource; import java.util.Collection; @@ -96,6 +86,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } + @Bean + public JwtAuthenticationProvider jwtAuthenticationProvider(JwtDecoder jwtDecoder) { + return new JwtAuthenticationProvider(jwtDecoder); + } + private CorsConfigurationSource corsConfigurationSource() { return SecurityConfig.createCorsConfiguration(config.getCors()); } @@ -108,35 +103,4 @@ private Converter grantedAuthoritiesExtractor( new HierarchicalRoleBasedAuthorityMapper().mapAuthorities(authorities)); }; } - - /** - * Part of {@link EnableWebSocketSecurity @EnableWebSocketSecurity} replacement - * - * @see WebSocketConfig - */ - @Bean - @Scope("prototype") - public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder( - ApplicationContext context) { - return MessageMatcherDelegatingAuthorizationManager.builder().simpDestPathMatcher( - () -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) - ? context.getBean(SimpAnnotationMethodMessageHandler.class).getPathMatcher() - : new AntPathMatcher()); - } - - /** - * WebSocket endpoint authorization - */ - @Bean - public AuthorizationManager> messageAuthorizationManager( - MessageMatcherDelegatingAuthorizationManager.Builder messages) { - return messages.simpTypeMatchers(SimpMessageType.DISCONNECT).permitAll() - .anyMessage().authenticated().build(); - } - - @Bean - public WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor(JwtUtils jwtUtils, - TermItUserDetailsService userDetailsService) { - return new WebSocketJwtAuthorizationInterceptor(jwtUtils, userDetailsService); - } } diff --git a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java index 85904dce6..098292702 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java @@ -40,6 +40,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.HttpStatusEntryPoint; @@ -121,6 +123,11 @@ private JwtAuthenticationFilter authenticationFilter(AuthenticationManager authe return authenticationFilter; } + @Bean + public JwtAuthenticationProvider jwtAuthenticationProvider(JwtDecoder jwtDecoder) { + return new JwtAuthenticationProvider(jwtDecoder); + } + private CorsConfigurationSource corsConfigurationSource() { return createCorsConfiguration(config.getCors()); } diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java index c177726da..36f15d990 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketConfig.java @@ -1,48 +1,25 @@ package cz.cvut.kbss.termit.config; import com.fasterxml.jackson.databind.ObjectMapper; -import cz.cvut.kbss.termit.security.JwtUtils; -import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; -import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; -import cz.cvut.kbss.termit.util.Constants; -import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; -import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Scope; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.messaging.Message; import org.springframework.messaging.converter.MappingJackson2MessageConverter; -import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.converter.StringMessageConverter; -import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; -import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; import org.springframework.messaging.simp.SimpMessageType; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler; -import org.springframework.messaging.simp.config.ChannelRegistration; -import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.security.authorization.AuthorizationManager; -import org.springframework.security.authorization.SpringAuthorizationEventPublisher; import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; -import org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor; import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; -import org.springframework.security.messaging.context.AuthenticationPrincipalArgumentResolver; -import org.springframework.security.messaging.context.SecurityContextChannelInterceptor; import org.springframework.util.AntPathMatcher; -import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; -import org.springframework.web.socket.config.annotation.StompEndpointRegistry; -import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; -import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import java.nio.charset.StandardCharsets; -import java.util.List; /* We are not using @EnableWebSocketSecurity @@ -50,92 +27,26 @@ it automatically requires CSRF which cannot be configured (disabled) at the mome (will probably change in the future) */ @Configuration -@EnableWebSocketMessageBroker -@Order(Ordered.HIGHEST_PRECEDENCE + 99) // ensures priority above Spring Security -public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - private final cz.cvut.kbss.termit.util.Configuration configuration; +@Order(Ordered.HIGHEST_PRECEDENCE + 98) // ensures priority above Spring Security +public class WebSocketConfig { private final ApplicationContext context; private final ObjectMapper jsonLdMapper; - private final SimpMessagingTemplate simpMessagingTemplate; - - private final JwtUtils jwtUtils; - - private final TermItUserDetailsService userDetailsService; - @Autowired - public WebSocketConfig(cz.cvut.kbss.termit.util.Configuration configuration, ApplicationContext context, - @Qualifier("jsonLdMapper") ObjectMapper jsonLdMapper, - @Lazy SimpMessagingTemplate simpMessagingTemplate, JwtUtils jwtUtils, - TermItUserDetailsService userDetailsService) { - this.configuration = configuration; + public WebSocketConfig(ApplicationContext context, @Qualifier("jsonLdMapper") ObjectMapper jsonLdMapper) { this.context = context; this.jsonLdMapper = jsonLdMapper; - this.simpMessagingTemplate = simpMessagingTemplate; - this.jwtUtils = jwtUtils; - this.userDetailsService = userDetailsService; - } - - /** - * WebSocket security setup (replaces {@link EnableWebSocketSecurity @EnableWebSocketSecurity}) - */ - @Override - public void addArgumentResolvers(List argumentResolvers) { - AuthenticationPrincipalArgumentResolver resolver = new AuthenticationPrincipalArgumentResolver(); - argumentResolvers.add(resolver); - } - - /** - * WebSocket security setup (replaces {@link EnableWebSocketSecurity @EnableWebSocketSecurity}) - * @see Spring security source - */ - @Override - public void configureClientInboundChannel(@NotNull ChannelRegistration registration) { - AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(messageAuthorizationManager()); - interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context)); - registration.interceptors(webSocketJwtAuthorizationInterceptor(), new SecurityContextChannelInterceptor(), interceptor); - } - - @Override - public void addReturnValueHandlers(List returnValueHandlers) { - returnValueHandlers.add(new WebSocketMessageWithHeadersValueHandler(simpMessagingTemplate)); - } - - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/ws").setAllowedOrigins(configuration.getCors().getAllowedOrigins().split(",")); - registry.setErrorHandler(new StompExceptionHandler()); - } - - @Override - public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.setApplicationDestinationPrefixes("/") - .setUserDestinationPrefix("/user"); - } - - @Override - public void configureWebSocketTransport(WebSocketTransportRegistration registry) { - registry.setTimeToFirstMessage(Constants.WEBSOCKET_TIME_TO_FIRST_MESSAGE); - registry.setSendBufferSizeLimit(Constants.WEBSOCKET_SEND_BUFFER_SIZE_LIMIT); - } - - @Override - public boolean configureMessageConverters(List messageConverters) { - messageConverters.add(termitJsonLdMessageConverter()); - messageConverters.add(termitStringMessageConverter()); - return false; // do not add default converters } @Bean - public MessageConverter termitStringMessageConverter() { + public StringMessageConverter termitStringMessageConverter() { return new StringMessageConverter(StandardCharsets.UTF_8); } @Bean - public MessageConverter termitJsonLdMessageConverter() { + public MappingJackson2MessageConverter termitJsonLdMessageConverter() { return new MappingJackson2MessageConverter(jsonLdMapper); } @@ -145,10 +56,9 @@ public MessageConverter termitJsonLdMessageConverter() { @Bean @Scope("prototype") public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder() { - return MessageMatcherDelegatingAuthorizationManager.builder().simpDestPathMatcher( - () -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) - ? context.getBean(SimpAnnotationMethodMessageHandler.class).getPathMatcher() - : new AntPathMatcher()); + return MessageMatcherDelegatingAuthorizationManager.builder() + .simpDestPathMatcher(() -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0) ? context.getBean(SimpAnnotationMethodMessageHandler.class) + .getPathMatcher() : new AntPathMatcher()); } /** @@ -157,11 +67,6 @@ public MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorization @Bean public AuthorizationManager> messageAuthorizationManager() { return messageAuthorizationManagerBuilder().simpTypeMatchers(SimpMessageType.DISCONNECT).permitAll() - .anyMessage().authenticated().build(); - } - - @Bean - public WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor() { - return new WebSocketJwtAuthorizationInterceptor(jwtUtils, userDetailsService); + .anyMessage().authenticated().build(); } } diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java new file mode 100644 index 000000000..16dbda3c2 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java @@ -0,0 +1,121 @@ +package cz.cvut.kbss.termit.config; + +import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; +import cz.cvut.kbss.termit.util.Constants; +import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; +import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.messaging.Message; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.converter.StringMessageConverter; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.SpringAuthorizationEventPublisher; +import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; +import org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor; +import org.springframework.security.messaging.context.AuthenticationPrincipalArgumentResolver; +import org.springframework.security.messaging.context.SecurityContextChannelInterceptor; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; + +import java.util.List; + +@Configuration +@EnableWebSocketMessageBroker +@Order(Ordered.HIGHEST_PRECEDENCE + 99) // ensures priority above Spring Security +public class WebSocketMessageBrokerConfig implements WebSocketMessageBrokerConfigurer { + + private final AuthorizationManager> messageAuthorizationManager; + + private final ApplicationContext context; + + private final WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor; + + private final SimpMessagingTemplate simpMessagingTemplate; + + private final String allowedOrigins; + + private final StringMessageConverter termitStringMessageConverter; + + private final MappingJackson2MessageConverter termitJsonLdMessageConverter; + + public WebSocketMessageBrokerConfig(AuthorizationManager> messageAuthorizationManager, + ApplicationContext context, + WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor, + @Lazy SimpMessagingTemplate simpMessagingTemplate, + StringMessageConverter termitStringMessageConverter, + MappingJackson2MessageConverter termitJsonLdMessageConverter, + cz.cvut.kbss.termit.util.Configuration configuration) { + this.messageAuthorizationManager = messageAuthorizationManager; + this.context = context; + this.webSocketJwtAuthorizationInterceptor = webSocketJwtAuthorizationInterceptor; + this.simpMessagingTemplate = simpMessagingTemplate; + this.termitStringMessageConverter = termitStringMessageConverter; + this.termitJsonLdMessageConverter = termitJsonLdMessageConverter; + + this.allowedOrigins = configuration.getCors().getAllowedOrigins(); + } + + /** + * WebSocket security setup (replaces {@link EnableWebSocketSecurity @EnableWebSocketSecurity}) + */ + @Override + public void addArgumentResolvers(List argumentResolvers) { + AuthenticationPrincipalArgumentResolver resolver = new AuthenticationPrincipalArgumentResolver(); + argumentResolvers.add(resolver); + } + + /** + * WebSocket security setup (replaces {@link EnableWebSocketSecurity @EnableWebSocketSecurity}) + * + * @see Spring security source + */ + @Override + public void configureClientInboundChannel(@NotNull ChannelRegistration registration) { + AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(messageAuthorizationManager); + interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(context)); + registration.interceptors(webSocketJwtAuthorizationInterceptor, new SecurityContextChannelInterceptor(), interceptor); + } + + @Override + public void addReturnValueHandlers(List returnValueHandlers) { + returnValueHandlers.add(new WebSocketMessageWithHeadersValueHandler(simpMessagingTemplate)); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOrigins(allowedOrigins.split(",")); + registry.setErrorHandler(new StompExceptionHandler()); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/") + .setUserDestinationPrefix("/user"); + } + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registry) { + registry.setTimeToFirstMessage(Constants.WEBSOCKET_TIME_TO_FIRST_MESSAGE); + registry.setSendBufferSizeLimit(Constants.WEBSOCKET_SEND_BUFFER_SIZE_LIMIT); + } + + @Override + public boolean configureMessageConverters(List messageConverters) { + messageConverters.add(termitJsonLdMessageConverter); + messageConverters.add(termitStringMessageConverter); + return false; // do not add default converters + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java index 71e5627de..875c2162c 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java +++ b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java @@ -1,10 +1,5 @@ package cz.cvut.kbss.termit.security; -import cz.cvut.kbss.termit.exception.AuthorizationException; -import cz.cvut.kbss.termit.exception.JwtException; -import cz.cvut.kbss.termit.security.model.TermItUserDetails; -import cz.cvut.kbss.termit.service.security.SecurityUtils; -import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import org.jetbrains.annotations.NotNull; import org.springframework.http.HttpHeaders; import org.springframework.messaging.Message; @@ -13,25 +8,32 @@ import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.messaging.support.MessageHeaderAccessor; -import org.springframework.security.authentication.DisabledException; -import org.springframework.security.authentication.LockedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; /** - * Authorizes STOMP CONNECT messages + * Authenticates STOMP CONNECT messages *

- * Retrieves token from the {@code Authorization} header of STOMP message and validates JWT token. + * Retrieves token from the {@code Authorization} header and authenticates the session. */ +@Component public class WebSocketJwtAuthorizationInterceptor implements ChannelInterceptor { - private final JwtUtils jwtUtils; + private final JwtAuthenticationProvider jwtAuthenticationProvider; - private final TermItUserDetailsService userDetailsService; + private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy(); - public WebSocketJwtAuthorizationInterceptor(JwtUtils jwtUtils, TermItUserDetailsService userDetailsService) { - this.jwtUtils = jwtUtils; - this.userDetailsService = userDetailsService; + public WebSocketJwtAuthorizationInterceptor(JwtAuthenticationProvider jwtAuthenticationProvider) { + this.jwtAuthenticationProvider = jwtAuthenticationProvider; } @Override @@ -39,27 +41,48 @@ public Message preSend(@NotNull Message message, @NotNull MessageChannel c StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); if (headerAccessor != null && StompCommand.CONNECT.equals(headerAccessor.getCommand()) && headerAccessor.isMutable()) { final String authHeader = headerAccessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); - if (authHeader != null && authHeader.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) { + if (authHeader != null) { headerAccessor.removeNativeHeader(HttpHeaders.AUTHORIZATION); - return process(message, authHeader, headerAccessor); + process(headerAccessor, authHeader); + return message; } - throw new AuthorizationException("Authorization header is invalid"); + throw new AuthenticationCredentialsNotFoundException("Invalid authorization header"); } return message; } - private Message process(final @NotNull Message message, final @NotNull String authHeader, - final @NotNull StompHeaderAccessor headerAccessor) { - final String authToken = authHeader.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); + /** + * Authenticates user using JWT token in authentication header + *

+ * According to Open ID spec, + * the token MUST be {@code Bearer}. + * And for example {@link org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider} + * also supports only {@code Bearer} tokens. + */ + protected void process(StompHeaderAccessor stompHeaderAccessor, final @NotNull String authHeader) { + if (!StringUtils.startsWithIgnoreCase(authHeader, SecurityConstants.JWT_TOKEN_PREFIX)) { + throw new InvalidBearerTokenException("Invalid Bearer token in authorization header"); + } + + final String token = authHeader.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); + + BearerTokenAuthenticationToken authenticationRequest = new BearerTokenAuthenticationToken(token); + try { - final TermItUserDetails userDetails = jwtUtils.extractUserInfo(authToken); - final TermItUserDetails existingDetails = userDetailsService.loadUserByUsername(userDetails.getUsername()); - SecurityUtils.verifyAccountStatus(existingDetails.getUser()); - Authentication authentication = SecurityUtils.setCurrentUser(existingDetails); - headerAccessor.setUser(authentication); - return message; - } catch (JwtException | DisabledException | LockedException | UsernameNotFoundException e) { - throw new AuthorizationException(e.getMessage()); + Authentication authenticationResult = jwtAuthenticationProvider.authenticate(authenticationRequest); + if (authenticationResult != null && authenticationResult.isAuthenticated()) { + SecurityContext context = this.securityContextHolderStrategy.createEmptyContext(); + context.setAuthentication(authenticationResult); + this.securityContextHolderStrategy.setContext(context); + stompHeaderAccessor.setUser(authenticationResult); + return; // all ok + } + throw new OAuth2AuthenticationException("Authentication failed"); + } catch (Exception e) { + // ensure that context is cleared when any exception happens + stompHeaderAccessor.setUser(null); + this.securityContextHolderStrategy.clearContext(); + throw e; } } } From 355c4e271cf3843239af5ff311866692ff782af7 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 08:21:08 +0200 Subject: [PATCH 066/150] TermitJwtDecoder --- .../kbss/termit/config/SecurityConfig.java | 12 ++++- .../security/JwtAuthorizationFilter.java | 18 +++++--- .../cvut/kbss/termit/security/JwtUtils.java | 33 ++++++++++---- .../termit/security/TermitJwtDecoder.java | 45 +++++++++++++++++++ .../WebSocketJwtAuthorizationInterceptor.java | 8 ++-- .../config/TestWebSocketConfig.java | 14 +++++- .../security/JwtAuthorizationFilterTest.java | 5 ++- .../BaseWebSocketControllerTestRunner.java | 3 +- .../BaseWebSocketIntegrationTestRunner.java | 13 ++---- 9 files changed, 116 insertions(+), 35 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java diff --git a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java index 098292702..d08c3fa71 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java @@ -23,6 +23,7 @@ import cz.cvut.kbss.termit.security.JwtAuthorizationFilter; import cz.cvut.kbss.termit.security.JwtUtils; import cz.cvut.kbss.termit.security.SecurityConstants; +import cz.cvut.kbss.termit.security.TermitJwtDecoder; import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import cz.cvut.kbss.termit.util.Constants; import org.slf4j.Logger; @@ -40,7 +41,9 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @@ -92,7 +95,7 @@ public SecurityConfig(AuthenticationProvider authenticationProvider, } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, TermitJwtDecoder jwtDecoder) throws Exception { LOG.debug("Using internal security mechanisms."); final AuthenticationManager authManager = buildAuthenticationManager(http); http.authorizeHttpRequests((auth) -> auth.requestMatchers(antMatcher("/rest/query")).permitAll() @@ -104,7 +107,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logoutSuccessHandler(authenticationSuccessHandler)) .authenticationManager(authManager) .addFilter(authenticationFilter(authManager)) - .addFilter(new JwtAuthorizationFilter(authManager, jwtUtils, userDetailsService, objectMapper)); + .addFilter(new JwtAuthorizationFilter(authManager, jwtUtils, userDetailsService, objectMapper, jwtDecoder)); return http.build(); } @@ -150,4 +153,9 @@ protected static CorsConfigurationSource createCorsConfiguration( source.registerCorsConfiguration("/**", corsConfiguration); return source; } + + @Bean + public TermitJwtDecoder jwtDecoder() { + return new TermitJwtDecoder(jwtUtils, userDetailsService); + } } diff --git a/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java b/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java index 06e87770b..fd39f1175 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java +++ b/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java @@ -31,6 +31,7 @@ import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import jakarta.servlet.FilterChain; @@ -64,12 +65,16 @@ public class JwtAuthorizationFilter extends BasicAuthenticationFilter { private final ObjectMapper objectMapper; + private final TermitJwtDecoder jwtDecoder; + public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JwtUtils jwtUtils, - TermItUserDetailsService userDetailsService, ObjectMapper objectMapper) { + TermItUserDetailsService userDetailsService, ObjectMapper objectMapper, + TermitJwtDecoder jwtDecoder) { super(authenticationManager); this.jwtUtils = jwtUtils; this.userDetailsService = userDetailsService; this.objectMapper = objectMapper; + this.jwtDecoder = jwtDecoder; } @Override @@ -82,13 +87,16 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } final String authToken = authHeader.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); try { - final TermItUserDetails userDetails = jwtUtils.extractUserInfo(authToken); - final TermItUserDetails existingDetails = userDetailsService.loadUserByUsername(userDetails.getUsername()); - SecurityUtils.verifyAccountStatus(existingDetails.getUser()); + Jwt jwt = jwtDecoder.decode(authToken); + final String username = jwt.getSubject(); + if (username == null || username.isBlank()) { + throw new JwtException("Invalid JWT token contents"); + } + final TermItUserDetails existingDetails = userDetailsService.loadUserByUsername(username); SecurityUtils.setCurrentUser(existingDetails); refreshToken(authToken, response); chain.doFilter(request, response); - } catch (JwtException e) { + } catch (JwtException | org.springframework.security.oauth2.jwt.JwtException e) { if (shouldAllowThroughUnauthenticated(request)) { chain.doFilter(request, response); } else { diff --git a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java index 54ca57bdb..3d8b52c4c 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java @@ -27,6 +27,8 @@ import cz.cvut.kbss.termit.util.Utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureAlgorithm; @@ -35,6 +37,7 @@ import io.jsonwebtoken.jackson.io.JacksonSerializer; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SecurityException; +import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.core.GrantedAuthority; @@ -63,11 +66,16 @@ public class JwtUtils { private final Key key; + private final JwtParser jwtParser; + @Autowired public JwtUtils(@Qualifier("objectMapper") ObjectMapper objectMapper, Configuration config) { this.objectMapper = objectMapper; this.key = Utils.isBlank(config.getJwt().getSecretKey()) ? Keys.secretKeyFor(SIGNATURE_ALGORITHM) : Keys.hmacShaKeyFor(config.getJwt().getSecretKey().getBytes(StandardCharsets.UTF_8)); + this.jwtParser = Jwts.parserBuilder().setSigningKey(key) + .deserializeJsonWith(new JacksonDeserializer<>(objectMapper)) + .build(); } /** @@ -109,7 +117,16 @@ private static String mapAuthoritiesToClaim(Collection getClaimsFromToken(String token) { try { return parseClaims(token); } catch (MalformedJwtException | UnsupportedJwtException e) { @@ -133,10 +150,8 @@ private Claims getClaimsFromToken(String token) { } } - private Claims parseClaims(String token) { - return Jwts.parserBuilder().setSigningKey(key) - .deserializeJsonWith(new JacksonDeserializer<>(objectMapper)) - .build().parseClaimsJws(token).getBody(); + private Jws parseClaims(String token) { + return jwtParser.parseClaimsJws(token); } private static void verifyAttributePresence(Claims claims) { @@ -171,7 +186,7 @@ private static List mapClaimToAuthorities(String claim) { */ public String refreshToken(String token) { Objects.requireNonNull(token); - final Claims claims = getClaimsFromToken(token); + final Claims claims = getClaimsFromToken(token).getBody(); final Instant issued = issueTimestamp(); claims.setIssuedAt(Date.from(issued)); claims.setExpiration(Date.from(issued.plusMillis(SecurityConstants.SESSION_TIMEOUT))); @@ -191,7 +206,7 @@ public String refreshToken(String token) { */ public URI getUserUri(String token) { try { - final Claims claims = parseClaims(token); + final Claims claims = parseClaims(token).getBody(); return URI.create(claims.getId()); } catch (ExpiredJwtException e) { return URI.create(e.getClaims().getId()); @@ -206,7 +221,7 @@ public URI getUserUri(String token) { */ public Instant getTokenIssueTimestamp(String token) { try { - final Claims claims = parseClaims(token); + final Claims claims = parseClaims(token).getBody(); return claims.getIssuedAt().toInstant(); } catch (ExpiredJwtException e) { return e.getClaims().getIssuedAt().toInstant(); diff --git a/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java b/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java new file mode 100644 index 000000000..e4b0b5241 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java @@ -0,0 +1,45 @@ +package cz.cvut.kbss.termit.security; + +import cz.cvut.kbss.termit.security.model.TermItUserDetails; +import cz.cvut.kbss.termit.service.security.SecurityUtils; +import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtException; + +import java.util.Objects; + +public class TermitJwtDecoder implements org.springframework.security.oauth2.jwt.JwtDecoder { + + private final JwtUtils jwtUtils; + + private final TermItUserDetailsService userDetailsService; + + public TermitJwtDecoder(JwtUtils jwtUtils, TermItUserDetailsService userDetailsService) { + this.jwtUtils = jwtUtils; + this.userDetailsService = userDetailsService; + } + + @Override + public Jwt decode(String token) throws JwtException { + try { + final Jws expanded = jwtUtils.getClaimsFromToken(token); + Objects.requireNonNull(expanded); + Objects.requireNonNull(expanded.getBody()); + Objects.requireNonNull(expanded.getHeader()); + final Claims claims = expanded.getBody(); + Objects.requireNonNull(claims.getIssuedAt()); + Objects.requireNonNull(claims.getExpiration()); + final TermItUserDetails tokenDetails = jwtUtils.extractUserInfo(claims); + final TermItUserDetails existingDetails = userDetailsService.loadUserByUsername(tokenDetails.getUsername()); + + SecurityUtils.verifyAccountStatus(existingDetails.getUser()); + + return new Jwt(token, claims.getIssuedAt().toInstant(), claims.getExpiration() + .toInstant(), expanded.getHeader(), claims); + } catch (cz.cvut.kbss.termit.exception.JwtException | NullPointerException e) { + throw new JwtException(e.getMessage(), e); + } + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java index 875c2162c..b1fffd73a 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java +++ b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java @@ -30,8 +30,6 @@ public class WebSocketJwtAuthorizationInterceptor implements ChannelInterceptor private final JwtAuthenticationProvider jwtAuthenticationProvider; - private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy(); - public WebSocketJwtAuthorizationInterceptor(JwtAuthenticationProvider jwtAuthenticationProvider) { this.jwtAuthenticationProvider = jwtAuthenticationProvider; } @@ -71,9 +69,9 @@ protected void process(StompHeaderAccessor stompHeaderAccessor, final @NotNull S try { Authentication authenticationResult = jwtAuthenticationProvider.authenticate(authenticationRequest); if (authenticationResult != null && authenticationResult.isAuthenticated()) { - SecurityContext context = this.securityContextHolderStrategy.createEmptyContext(); + SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authenticationResult); - this.securityContextHolderStrategy.setContext(context); + SecurityContextHolder.setContext(context); stompHeaderAccessor.setUser(authenticationResult); return; // all ok } @@ -81,7 +79,7 @@ protected void process(StompHeaderAccessor stompHeaderAccessor, final @NotNull S } catch (Exception e) { // ensure that context is cleared when any exception happens stompHeaderAccessor.setUser(null); - this.securityContextHolderStrategy.clearContext(); + SecurityContextHolder.clearContext(); throw e; } } diff --git a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java index f3cd62e7d..213775e12 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java @@ -2,6 +2,8 @@ import cz.cvut.kbss.termit.config.WebAppConfig; import cz.cvut.kbss.termit.config.WebSocketConfig; +import cz.cvut.kbss.termit.config.WebSocketMessageBrokerConfig; +import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.websocket.util.ReturnValueCollectingSimpMessagingTemplate; import org.jetbrains.annotations.NotNull; @@ -24,6 +26,8 @@ import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.messaging.support.AbstractSubscribableChannel; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import java.util.HashMap; @@ -32,9 +36,10 @@ import java.util.UUID; @TestConfiguration +@EnableWebSocketMessageBroker @EnableConfigurationProperties(Configuration.class) -@Import({TestSecurityConfig.class, TestRestSecurityConfig.class, WebAppConfig.class, WebSocketConfig.class}) -@ComponentScan(basePackages = "cz.cvut.kbss.termit.websocket") +@Import({TestSecurityConfig.class, TestRestSecurityConfig.class, WebAppConfig.class, WebSocketConfig.class, WebSocketMessageBrokerConfig.class}) +@ComponentScan(basePackages = {"cz.cvut.kbss.termit.websocket"}) public class TestWebSocketConfig implements ApplicationListener, WebSocketMessageBrokerConfigurer { @@ -95,4 +100,9 @@ public SimpMessagingTemplate brokerMessagingTemplate( template.setMessageConverter(brokerMessageConverter); return template; } + + @Bean + public WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor(JwtAuthenticationProvider jwtAuthenticationProvider) { + return new WebSocketJwtAuthorizationInterceptor(jwtAuthenticationProvider); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java b/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java index e273fa08a..450ba09e5 100644 --- a/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java @@ -93,6 +93,8 @@ class JwtAuthorizationFilterTest { private JwtAuthorizationFilter sut; + private TermitJwtDecoder termitJwtDecoder; + private final Instant tokenIssued = JwtUtils.issueTimestamp(); @BeforeEach @@ -101,8 +103,9 @@ void setUp() { this.objectMapper = Environment.getObjectMapper(); this.signingKey = Keys.hmacShaKeyFor(config.getJwt().getSecretKey().getBytes(StandardCharsets.UTF_8)); this.jwtUtilsSpy = spy(new JwtUtils(objectMapper, config)); + this.termitJwtDecoder = new TermitJwtDecoder(jwtUtilsSpy, detailsServiceMock); this.sut = new JwtAuthorizationFilter(authManagerMock, jwtUtilsSpy, detailsServiceMock, - objectMapper); + objectMapper, termitJwtDecoder); } @AfterEach diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java index 4c4c944d8..da6684097 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java @@ -1,5 +1,6 @@ package cz.cvut.kbss.termit.websocket; +import cz.cvut.kbss.termit.environment.config.TestRestSecurityConfig; import cz.cvut.kbss.termit.environment.config.TestWebSocketConfig; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.websocket.util.CachingChannelInterceptor; @@ -31,7 +32,7 @@ @ExtendWith(MockitoExtension.class) @EnableConfigurationProperties({Configuration.class}) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) -@ContextConfiguration(classes = {TestWebSocketConfig.class}, +@ContextConfiguration(classes = {TestRestSecurityConfig.class, TestWebSocketConfig.class}, initializers = {ConfigDataApplicationContextInitializer.class}) public abstract class BaseWebSocketControllerTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java index 59ba7d502..2e2678e92 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java @@ -4,10 +4,10 @@ import cz.cvut.kbss.termit.config.SecurityConfig; import cz.cvut.kbss.termit.config.WebAppConfig; import cz.cvut.kbss.termit.config.WebSocketConfig; +import cz.cvut.kbss.termit.config.WebSocketMessageBrokerConfig; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.environment.config.TestConfig; import cz.cvut.kbss.termit.environment.config.TestPersistenceConfig; -import cz.cvut.kbss.termit.environment.config.TestSecurityConfig; import cz.cvut.kbss.termit.environment.config.TestServiceConfig; import cz.cvut.kbss.termit.security.JwtUtils; import cz.cvut.kbss.termit.security.model.TermItUserDetails; @@ -16,19 +16,14 @@ import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Answers; -import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.AutoConfigureOrder; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.EnableAspectJAutoProxy; @@ -37,7 +32,6 @@ import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; @@ -53,7 +47,6 @@ import java.util.concurrent.atomic.AtomicReference; import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; @ActiveProfiles("test") @EnableSpringConfigured @@ -63,8 +56,8 @@ @EnableAspectJAutoProxy(proxyTargetClass = true) @EnableConfigurationProperties({Configuration.class}) @ContextConfiguration( - classes = {TestConfig.class, TestPersistenceConfig.class, TestConfig.class, - TestServiceConfig.class, AppConfig.class, SecurityConfig.class, WebAppConfig.class, WebSocketConfig.class}, + classes = {TestConfig.class, TestPersistenceConfig.class, TestServiceConfig.class, AppConfig.class, + SecurityConfig.class, WebAppConfig.class, WebSocketConfig.class, WebSocketMessageBrokerConfig.class}, initializers = {ConfigDataApplicationContextInitializer.class}) @ComponentScan("cz.cvut.kbss.termit.security") @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) From de338e8ef622b804e1559946a2eb4bb22e26dce9 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 09:49:19 +0200 Subject: [PATCH 067/150] fix JwtAuthenticationProvider and missing authorities --- doc/setup.md | 37 ++++++++++++++++--- .../termit/config/OAuth2SecurityConfig.java | 8 +++- .../kbss/termit/config/SecurityConfig.java | 17 +++++++-- .../termit/security/TermitJwtDecoder.java | 15 ++++++++ .../WebSocketJwtAuthorizationInterceptor.java | 7 +++- 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/doc/setup.md b/doc/setup.md index f5555e8fe..81dad0f81 100644 --- a/doc/setup.md +++ b/doc/setup.md @@ -172,10 +172,35 @@ termit: TermIt can operate in two authentication modes: -1. Internal authentication means -2. [Keycloak](https://www.keycloak.org/) -based +1. Internal authentication +2. OAuth2 based (e.g. [Keycloak](https://www.keycloak.org/)) + +By default, OAuth2 is disabled and internal authentication is used +To enable it, set termit security provider to `oidc` +and provide issuer-uri and jwk-set-uri. + +**`application.yml` example:** +```yml +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://keycloak.lan/realms/termit + jwk-set-uri: http://keycloak.lan/realms/termit/protocol/openid-connect/certs +termit: + security: + provider: "oidc" +``` + +**Environmental variables example:** +``` +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=http://keycloak.lan/realms/termit +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWKSETURI=http://keycloak.lan/realms/termit/protocol/openid-connect/certs +TERMIT_SECURITY_PROVIDER=oidc +``` + +TermIt will automatically configure its security accordingly +(it is using Spring's [`ConditionalOnProperty`](https://www.baeldung.com/spring-conditionalonproperty)). -By default, Keycloak is disabled (see `keycloak.enabled` in `application.yml`). To enable it, set `keycloak.enabled` to `true` and -provide additional required Keycloak parameters - see the [Keycloak Spring Boot integration docs](https://www.keycloak.org/docs/latest/securing_apps/#_spring_boot_adapter). -TermIt will automatically configure its security (it is using Spring's [`ConditionalOnProperty`](https://www.baeldung.com/spring-conditionalonproperty)) -accordingly. +**Note that termit-ui needs to be configured for mathcing authentication mode.** diff --git a/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java index 32623b9a2..cb5081690 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java @@ -86,9 +86,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } + /** + * An attempt to replicate auth provider from HttpSecurity + * @see cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor + */ @Bean public JwtAuthenticationProvider jwtAuthenticationProvider(JwtDecoder jwtDecoder) { - return new JwtAuthenticationProvider(jwtDecoder); + final JwtAuthenticationProvider provider = new JwtAuthenticationProvider(jwtDecoder); + provider.setJwtAuthenticationConverter(grantedAuthoritiesExtractor()); + return provider; } private CorsConfigurationSource corsConfigurationSource() { diff --git a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java index d08c3fa71..a8d22070b 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java @@ -41,10 +41,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.HttpStatusEntryPoint; @@ -126,9 +126,20 @@ private JwtAuthenticationFilter authenticationFilter(AuthenticationManager authe return authenticationFilter; } + /** + * @see cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor + */ @Bean public JwtAuthenticationProvider jwtAuthenticationProvider(JwtDecoder jwtDecoder) { - return new JwtAuthenticationProvider(jwtDecoder); + final JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthorityPrefix(""); // this removes default "SCOPE_" prefix + // otherwise, all granted authorities would have this prefix + // (like "SCOPE_ROLE_RESTRICTED_USER", we want just ROLE_...) + final JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); + final JwtAuthenticationProvider provider = new JwtAuthenticationProvider(jwtDecoder); + provider.setJwtAuthenticationConverter(converter); + return provider; } private CorsConfigurationSource corsConfigurationSource() { diff --git a/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java b/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java index e4b0b5241..4cbd64db4 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java +++ b/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java @@ -5,11 +5,17 @@ import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.oauth2.jwt.JwtException; import java.util.Objects; +import java.util.stream.Collectors; +/** + * @see #decode(String) + */ public class TermitJwtDecoder implements org.springframework.security.oauth2.jwt.JwtDecoder { private final JwtUtils jwtUtils; @@ -21,6 +27,11 @@ public TermitJwtDecoder(JwtUtils jwtUtils, TermItUserDetailsService userDetailsS this.userDetailsService = userDetailsService; } + /** + * Decodes JWT token (without the {@code Bearer} prefix) + * and ensures its validity. + * @throws JwtException with cause, when token could not be decoded or verified + */ @Override public Jwt decode(String token) throws JwtException { try { @@ -36,6 +47,10 @@ public Jwt decode(String token) throws JwtException { SecurityUtils.verifyAccountStatus(existingDetails.getUser()); + claims.put("scope", existingDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet())); + claims.putIfAbsent(JwtClaimNames.SUB, existingDetails); + return new Jwt(token, claims.getIssuedAt().toInstant(), claims.getExpiration() .toInstant(), expanded.getHeader(), claims); } catch (cz.cvut.kbss.termit.exception.JwtException | NullPointerException e) { diff --git a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java index b1fffd73a..026c218a3 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java +++ b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java @@ -1,5 +1,7 @@ package cz.cvut.kbss.termit.security; +import cz.cvut.kbss.termit.security.model.TermItUserDetails; +import cz.cvut.kbss.termit.service.security.SecurityUtils; import org.jetbrains.annotations.NotNull; import org.springframework.http.HttpHeaders; import org.springframework.messaging.Message; @@ -12,7 +14,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; @@ -23,7 +24,9 @@ /** * Authenticates STOMP CONNECT messages *

- * Retrieves token from the {@code Authorization} header and authenticates the session. + * Retrieves token from the {@code Authorization} header + * and uses {@link JwtAuthenticationProvider} to authenticate the token. + * @see Consult this Stackoverflow answer */ @Component public class WebSocketJwtAuthorizationInterceptor implements ChannelInterceptor { From 90fadb6c73518aae6f546bc4d82ea93b625a7248 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 11:35:15 +0200 Subject: [PATCH 068/150] exception handling --- .../config/WebSocketMessageBrokerConfig.java | 9 ++- .../rest/handler/RestExceptionHandler.java | 24 +++++++ .../handler/StompExceptionHandler.java | 64 ++++++++++++++++++- .../handler/WebSocketExceptionHandler.java | 42 +++++++++++- 4 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java index 16dbda3c2..ceefa1273 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java @@ -3,6 +3,7 @@ import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; +import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; import org.jetbrains.annotations.NotNull; import org.springframework.context.ApplicationContext; @@ -51,13 +52,16 @@ public class WebSocketMessageBrokerConfig implements WebSocketMessageBrokerConfi private final MappingJackson2MessageConverter termitJsonLdMessageConverter; + private final WebSocketExceptionHandler webSocketExceptionHandler; + public WebSocketMessageBrokerConfig(AuthorizationManager> messageAuthorizationManager, ApplicationContext context, WebSocketJwtAuthorizationInterceptor webSocketJwtAuthorizationInterceptor, @Lazy SimpMessagingTemplate simpMessagingTemplate, StringMessageConverter termitStringMessageConverter, MappingJackson2MessageConverter termitJsonLdMessageConverter, - cz.cvut.kbss.termit.util.Configuration configuration) { + cz.cvut.kbss.termit.util.Configuration configuration, + WebSocketExceptionHandler webSocketExceptionHandler) { this.messageAuthorizationManager = messageAuthorizationManager; this.context = context; this.webSocketJwtAuthorizationInterceptor = webSocketJwtAuthorizationInterceptor; @@ -66,6 +70,7 @@ public WebSocketMessageBrokerConfig(AuthorizationManager> messageAuth this.termitJsonLdMessageConverter = termitJsonLdMessageConverter; this.allowedOrigins = configuration.getCors().getAllowedOrigins(); + this.webSocketExceptionHandler = webSocketExceptionHandler; } /** @@ -97,7 +102,7 @@ public void addReturnValueHandlers(List returnV @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").setAllowedOrigins(allowedOrigins.split(",")); - registry.setErrorHandler(new StompExceptionHandler()); + registry.setErrorHandler(new StompExceptionHandler(webSocketExceptionHandler)); } @Override diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index 90eb63cac..45c1af3a4 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -43,6 +43,9 @@ import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -127,6 +130,27 @@ public ResponseEntity authorizationException(HttpServletRequest reque return new ResponseEntity<>(errorInfo(request, e), HttpStatus.FORBIDDEN); } + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity authenticationException(HttpServletRequest request, AuthenticationException e) { + LOG.warn("Authentication failure during HTTP request to {}: {}", request.getRequestURI(), e.getMessage()); + LOG.atDebug().setCause(e).log(e.getMessage()); + return new ResponseEntity<>(errorInfo(request, e), HttpStatus.FORBIDDEN); + } + + /** + * Fired, for example, on method security violation + */ + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity accessDeniedException(HttpServletRequest request, AccessDeniedException e) { + LOG.atWarn().setMessage("[{}] Unauthorized access: {}").addArgument(() -> { + if (request.getUserPrincipal() != null) { + return request.getUserPrincipal().getName(); + } + return "(unknown user)"; + }).addArgument(e.getMessage()).log(); + return new ResponseEntity<>(errorInfo(request, e), HttpStatus.FORBIDDEN); + } + @ExceptionHandler(ValidationException.class) public ResponseEntity validationException(HttpServletRequest request, ValidationException e) { logException(e, request); diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java index 4f78bf920..4981eed7c 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java @@ -5,17 +5,75 @@ import org.slf4j.LoggerFactory; import org.springframework.messaging.Message; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * calls {@link WebSocketExceptionHandler} when possible, otherwise logs exception as error + */ public class StompExceptionHandler extends StompSubProtocolErrorHandler { private static final Logger LOG = LoggerFactory.getLogger(StompExceptionHandler.class); + private final WebSocketExceptionHandler webSocketExceptionHandler; + + public StompExceptionHandler(WebSocketExceptionHandler webSocketExceptionHandler) { + this.webSocketExceptionHandler = webSocketExceptionHandler; + } + @Override protected @NotNull Message handleInternal(@NotNull StompHeaderAccessor errorHeaderAccessor, - byte @NotNull [] errorPayload, - Throwable cause, StompHeaderAccessor clientHeaderAccessor) { - LOG.error("STOMP sub-protocol exception", cause); + byte @NotNull [] errorPayload, Throwable cause, + StompHeaderAccessor clientHeaderAccessor) { + final Message message = MessageBuilder.withPayload(errorPayload).setHeaders(errorHeaderAccessor).build(); + boolean handled = false; + try { + handled = delegate(message, cause); + } catch (InvocationTargetException e) { + LOG.error("Exception thrown during exception handler invocation", e); + } catch (IllegalAccessException unexpected) { + // is checked by delegate + } + + if (!handled) { + LOG.error("STOMP sub-protocol exception", cause); + } + return super.handleInternal(errorHeaderAccessor, errorPayload, cause, clientHeaderAccessor); } + + /** + * Tries to match method on {@link #webSocketExceptionHandler} + * + * @return true when a method was found and called, false otherwise + * @throws IllegalArgumentException never + */ + private boolean delegate(Message message, Throwable throwable) + throws InvocationTargetException, IllegalAccessException { + if (throwable instanceof Exception exception) { + Method[] methods = webSocketExceptionHandler.getClass().getMethods(); + for (final Method method : methods) { + if (!method.canAccess(webSocketExceptionHandler)) { + continue; + } + Class[] params = method.getParameterTypes(); + if (params.length != 2) { + continue; + } + if (params[0].isAssignableFrom(message.getClass()) && params[1].isAssignableFrom(exception.getClass())) { + // message, exception + method.invoke(webSocketExceptionHandler, message, exception); + return true; + } else if (params[0].isAssignableFrom(exception.getClass()) && params[1].isAssignableFrom(message.getClass())) { + // exception, message + method.invoke(webSocketExceptionHandler, exception, message); + return true; + } + } + } + return false; + } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index 50411c650..f412fc0f6 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -30,10 +30,15 @@ import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.simp.annotation.SendToUser; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import java.util.HashSet; +import java.util.Set; + /** * @implSpec Should reflect {@link cz.cvut.kbss.termit.rest.handler.RestExceptionHandler} */ @@ -44,7 +49,12 @@ public class WebSocketExceptionHandler { private static final Logger LOG = LoggerFactory.getLogger(WebSocketExceptionHandler.class); private static String destination(Message message) { - return message.getHeaders().getOrDefault("destination", "missing destination").toString(); + return message.getHeaders().getOrDefault("destination", "(missing destination)").toString(); + } + + private static boolean hasDestination(Message message) { + final String dst = (String) message.getHeaders().getOrDefault("destination", ""); + return dst != null && !dst.isBlank(); } private static void logException(TermItException ex, Message message) { @@ -72,8 +82,12 @@ private static ErrorInfo errorInfo(Message message, Throwable e) { @MessageExceptionHandler public void messageDeliveryException(Message message, MessageDeliveryException e) { - final StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message); - LOG.error("Failed to send message with destination {}: {}", headerAccessor.getDestination(), e.getMessage()); + // messages without destination will be logged only on trace + (hasDestination(message) ? LOG.atError() : LOG.atTrace()) + .setMessage("Failed to send message with destination {}: {}") + .addArgument(()-> destination(message)) + .addArgument(e.getMessage()) + .log(); } @MessageExceptionHandler(PersistenceException.class) @@ -117,6 +131,28 @@ public ErrorInfo authorizationException(Message message, AuthorizationExcepti return errorInfo(message, e); } + @MessageExceptionHandler(AuthenticationException.class) + public ErrorInfo authenticationException(Message message, AuthenticationException e) { + LOG.atDebug().setCause(e).log(e.getMessage()); + LOG.error("Authentication failure during message processing: {}\nMessage: {}", e.getMessage(), message.toString()); + return errorInfo(message, e); + } + + /** + * Fired, for example, on method security violation + */ + @MessageExceptionHandler(AccessDeniedException.class) + public ErrorInfo accessDeniedException(Message message, AccessDeniedException e) { + LOG.atWarn().setMessage("[{}] Unauthorized access: {}").addArgument(() -> { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + if (accessor.getUser() != null) { + return accessor.getUser().getName(); + } + return "(unknown user)"; + }).addArgument(e.getMessage()).log(); + return errorInfo(message, e); + } + @MessageExceptionHandler(ValidationException.class) public ErrorInfo validationException(Message message, ValidationException e) { logException(e, message); From cb223367796443465a2ec38a83aaf7c70477aa21 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 11:46:35 +0200 Subject: [PATCH 069/150] PR adjustments --- .../termit/config/OAuth2SecurityConfig.java | 2 +- .../kbss/termit/config/SecurityConfig.java | 2 +- .../security/JwtAuthorizationFilter.java | 20 +++++++++---------- .../termit/security/TermitJwtDecoder.java | 2 +- .../security/JwtAuthorizationFilterTest.java | 3 +-- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java index cb5081690..db6435aa2 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/OAuth2SecurityConfig.java @@ -87,7 +87,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { } /** - * An attempt to replicate auth provider from HttpSecurity + * Supplies auth provider which is not exposed by HttpSecurity * @see cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor */ @Bean diff --git a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java index a8d22070b..aa14405ec 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SecurityConfig.java @@ -107,7 +107,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, TermitJwtDecoder jwtDe .logoutSuccessHandler(authenticationSuccessHandler)) .authenticationManager(authManager) .addFilter(authenticationFilter(authManager)) - .addFilter(new JwtAuthorizationFilter(authManager, jwtUtils, userDetailsService, objectMapper, jwtDecoder)); + .addFilter(new JwtAuthorizationFilter(authManager, jwtUtils, objectMapper, jwtDecoder)); return http.build(); } diff --git a/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java b/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java index fd39f1175..0059b8b74 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java +++ b/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import cz.cvut.kbss.termit.exception.JwtException; +import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.rest.ConfigurationController; import cz.cvut.kbss.termit.rest.LanguageController; import cz.cvut.kbss.termit.rest.handler.ErrorInfo; @@ -32,6 +33,7 @@ import org.springframework.security.authentication.LockedException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import jakarta.servlet.FilterChain; @@ -61,18 +63,14 @@ public class JwtAuthorizationFilter extends BasicAuthenticationFilter { private final JwtUtils jwtUtils; - private final TermItUserDetailsService userDetailsService; - private final ObjectMapper objectMapper; private final TermitJwtDecoder jwtDecoder; - public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JwtUtils jwtUtils, - TermItUserDetailsService userDetailsService, ObjectMapper objectMapper, + public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JwtUtils jwtUtils, ObjectMapper objectMapper, TermitJwtDecoder jwtDecoder) { super(authenticationManager); this.jwtUtils = jwtUtils; - this.userDetailsService = userDetailsService; this.objectMapper = objectMapper; this.jwtDecoder = jwtDecoder; } @@ -88,14 +86,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse final String authToken = authHeader.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); try { Jwt jwt = jwtDecoder.decode(authToken); - final String username = jwt.getSubject(); - if (username == null || username.isBlank()) { + final Object principal = jwt.getClaim(JwtClaimNames.SUB); + if (principal instanceof TermItUserDetails existingDetails) { + SecurityUtils.setCurrentUser(existingDetails); + refreshToken(authToken, response); + chain.doFilter(request, response); + } else { throw new JwtException("Invalid JWT token contents"); } - final TermItUserDetails existingDetails = userDetailsService.loadUserByUsername(username); - SecurityUtils.setCurrentUser(existingDetails); - refreshToken(authToken, response); - chain.doFilter(request, response); } catch (JwtException | org.springframework.security.oauth2.jwt.JwtException e) { if (shouldAllowThroughUnauthenticated(request)) { chain.doFilter(request, response); diff --git a/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java b/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java index 4cbd64db4..e46777b14 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java +++ b/src/main/java/cz/cvut/kbss/termit/security/TermitJwtDecoder.java @@ -49,7 +49,7 @@ public Jwt decode(String token) throws JwtException { claims.put("scope", existingDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority) .collect(Collectors.toSet())); - claims.putIfAbsent(JwtClaimNames.SUB, existingDetails); + claims.put(JwtClaimNames.SUB, existingDetails); return new Jwt(token, claims.getIssuedAt().toInstant(), claims.getExpiration() .toInstant(), expanded.getHeader(), claims); diff --git a/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java b/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java index 450ba09e5..f9d0d3502 100644 --- a/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java @@ -104,8 +104,7 @@ void setUp() { this.signingKey = Keys.hmacShaKeyFor(config.getJwt().getSecretKey().getBytes(StandardCharsets.UTF_8)); this.jwtUtilsSpy = spy(new JwtUtils(objectMapper, config)); this.termitJwtDecoder = new TermitJwtDecoder(jwtUtilsSpy, detailsServiceMock); - this.sut = new JwtAuthorizationFilter(authManagerMock, jwtUtilsSpy, detailsServiceMock, - objectMapper, termitJwtDecoder); + this.sut = new JwtAuthorizationFilter(authManagerMock, jwtUtilsSpy, objectMapper, termitJwtDecoder); } @AfterEach From 09637f4e7c3e1cfc519d0262576351415ce62576 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 11:58:39 +0200 Subject: [PATCH 070/150] fix tests --- .../termit/websocket/handler/WebSocketExceptionHandler.java | 1 + .../kbss/termit/environment/config/TestWebSocketConfig.java | 3 ++- .../termit/websocket/BaseWebSocketIntegrationTestRunner.java | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index f412fc0f6..14f511490 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -87,6 +87,7 @@ public void messageDeliveryException(Message message, MessageDeliveryExceptio .setMessage("Failed to send message with destination {}: {}") .addArgument(()-> destination(message)) .addArgument(e.getMessage()) + .setCause(e.getCause()) .log(); } diff --git a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java index 213775e12..c20562443 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java @@ -5,6 +5,7 @@ import cz.cvut.kbss.termit.config.WebSocketMessageBrokerConfig; import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; import cz.cvut.kbss.termit.websocket.util.ReturnValueCollectingSimpMessagingTemplate; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; @@ -39,7 +40,7 @@ @EnableWebSocketMessageBroker @EnableConfigurationProperties(Configuration.class) @Import({TestSecurityConfig.class, TestRestSecurityConfig.class, WebAppConfig.class, WebSocketConfig.class, WebSocketMessageBrokerConfig.class}) -@ComponentScan(basePackages = {"cz.cvut.kbss.termit.websocket"}) +@ComponentScan(basePackages = {"cz.cvut.kbss.termit.websocket", "cz.cvut.kbss.termit.websocket.handler"}) public class TestWebSocketConfig implements ApplicationListener, WebSocketMessageBrokerConfigurer { diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java index 2e2678e92..ce30493d2 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java @@ -59,7 +59,8 @@ classes = {TestConfig.class, TestPersistenceConfig.class, TestServiceConfig.class, AppConfig.class, SecurityConfig.class, WebAppConfig.class, WebSocketConfig.class, WebSocketMessageBrokerConfig.class}, initializers = {ConfigDataApplicationContextInitializer.class}) -@ComponentScan("cz.cvut.kbss.termit.security") +@ComponentScan( + {"cz.cvut.kbss.termit.security", "cz.cvut.kbss.termit.websocket", "cz.cvut.kbss.termit.websocket.handler"}) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public abstract class BaseWebSocketIntegrationTestRunner { From f631e0e3dfc6b79e7cdbf4467a29ef922dcfdac4 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 12:25:09 +0200 Subject: [PATCH 071/150] fix principal resolving --- .../termit/service/security/SecurityUtils.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/security/SecurityUtils.java b/src/main/java/cz/cvut/kbss/termit/service/security/SecurityUtils.java index 758e82a33..567151339 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/security/SecurityUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/service/security/SecurityUtils.java @@ -24,6 +24,7 @@ import cz.cvut.kbss.termit.security.model.TermItUserDetails; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; +import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.DisabledException; @@ -35,6 +36,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.stereotype.Service; import java.util.Objects; @@ -70,12 +72,17 @@ public SecurityUtils(UserDetailsService userDetailsService, PasswordEncoder pass public UserAccount getCurrentUser() { final SecurityContext context = SecurityContextHolder.getContext(); assert context != null && context.getAuthentication().isAuthenticated(); - if (context.getAuthentication().getPrincipal() instanceof Jwt) { + if (context.getAuthentication().getPrincipal() instanceof Jwt jwt) { + Object principal = jwt.getClaim(JwtClaimNames.SUB); + if(principal instanceof TermItUserDetails termItUserDetails) { + return termItUserDetails.getUser(); + } + return resolveAccountFromOAuthPrincipal(context); - } else { - final TermItUserDetails userDetails = (TermItUserDetails) context.getAuthentication().getDetails(); - return userDetails.getUser(); } + + final TermItUserDetails userDetails = (TermItUserDetails) context.getAuthentication().getDetails(); + return userDetails.getUser(); } private UserAccount resolveAccountFromOAuthPrincipal(SecurityContext context) { From 79d420639fccdbd48e083c195328688619913d81 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 12:50:52 +0200 Subject: [PATCH 072/150] optimize imports --- .../rest/handler/RestExceptionHandler.java | 4 +--- .../termit/security/JwtAuthorizationFilter.java | 10 ++++------ .../WebSocketJwtAuthorizationInterceptor.java | 2 -- .../termit/service/security/SecurityUtils.java | 1 - .../handler/WebSocketExceptionHandler.java | 3 --- .../environment/config/TestWebSocketConfig.java | 1 - .../security/JwtAuthenticationFilterTest.java | 6 ++++-- .../security/JwtAuthorizationFilterTest.java | 16 ++++++++++++---- 8 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index 45c1af3a4..03d50a199 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -39,11 +39,11 @@ import cz.cvut.kbss.termit.exception.WebServiceIntegrationException; import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -51,8 +51,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; -import jakarta.servlet.http.HttpServletRequest; - /** * Exception handlers for REST controllers. *

diff --git a/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java b/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java index 0059b8b74..12143d6dc 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java +++ b/src/main/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilter.java @@ -19,13 +19,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import cz.cvut.kbss.termit.exception.JwtException; -import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.rest.ConfigurationController; import cz.cvut.kbss.termit.rest.LanguageController; import cz.cvut.kbss.termit.rest.handler.ErrorInfo; import cz.cvut.kbss.termit.security.model.TermItUserDetails; import cz.cvut.kbss.termit.service.security.SecurityUtils; -import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; @@ -36,10 +38,6 @@ import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Arrays; import java.util.HashSet; diff --git a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java index 026c218a3..eda4786a7 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java +++ b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java @@ -1,7 +1,5 @@ package cz.cvut.kbss.termit.security; -import cz.cvut.kbss.termit.security.model.TermItUserDetails; -import cz.cvut.kbss.termit.service.security.SecurityUtils; import org.jetbrains.annotations.NotNull; import org.springframework.http.HttpHeaders; import org.springframework.messaging.Message; diff --git a/src/main/java/cz/cvut/kbss/termit/service/security/SecurityUtils.java b/src/main/java/cz/cvut/kbss/termit/service/security/SecurityUtils.java index 567151339..18aa2993e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/security/SecurityUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/service/security/SecurityUtils.java @@ -24,7 +24,6 @@ import cz.cvut.kbss.termit.security.model.TermItUserDetails; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.DisabledException; diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index 14f511490..e94b99450 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -36,9 +36,6 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; -import java.util.HashSet; -import java.util.Set; - /** * @implSpec Should reflect {@link cz.cvut.kbss.termit.rest.handler.RestExceptionHandler} */ diff --git a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java index c20562443..1ce9b63fd 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java @@ -5,7 +5,6 @@ import cz.cvut.kbss.termit.config.WebSocketMessageBrokerConfig; import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; import cz.cvut.kbss.termit.util.Configuration; -import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; import cz.cvut.kbss.termit.websocket.util.ReturnValueCollectingSimpMessagingTemplate; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/test/java/cz/cvut/kbss/termit/security/JwtAuthenticationFilterTest.java b/src/test/java/cz/cvut/kbss/termit/security/JwtAuthenticationFilterTest.java index 1294826fa..61e494d77 100644 --- a/src/test/java/cz/cvut/kbss/termit/security/JwtAuthenticationFilterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/security/JwtAuthenticationFilterTest.java @@ -28,6 +28,7 @@ import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; +import jakarta.servlet.FilterChain; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -43,11 +44,12 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; -import jakarta.servlet.FilterChain; import java.nio.charset.StandardCharsets; import java.util.Collections; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @Tag("security") diff --git a/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java b/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java index f9d0d3502..49813826d 100644 --- a/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/security/JwtAuthorizationFilterTest.java @@ -25,11 +25,11 @@ import cz.cvut.kbss.termit.rest.ConfigurationController; import cz.cvut.kbss.termit.rest.handler.ErrorInfo; import cz.cvut.kbss.termit.security.model.TermItUserDetails; -import cz.cvut.kbss.termit.service.security.SecurityUtils; import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import cz.cvut.kbss.termit.util.Configuration; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; +import jakarta.servlet.FilterChain; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; @@ -49,7 +49,6 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; -import jakarta.servlet.FilterChain; import java.nio.charset.StandardCharsets; import java.security.Key; import java.time.Instant; @@ -59,8 +58,17 @@ import static cz.cvut.kbss.termit.util.Constants.REST_MAPPING_PATH; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @Tag("security") @ExtendWith({SpringExtension.class, MockitoExtension.class}) From 61dc2b94daafa6a182825b2614348ff7186d797f Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 13:03:33 +0200 Subject: [PATCH 073/150] optimize imports --- .../termit/config/SessionTimeoutManager.java | 5 ++-- .../kbss/termit/dto/AggregatedChangeInfo.java | 6 ++++- .../kbss/termit/dto/ConfigurationDto.java | 6 ++++- .../cz/cvut/kbss/termit/dto/RdfsResource.java | 8 ++++++- .../termit/dto/RecentlyCommentedAsset.java | 10 +++++++- .../termit/dto/RecentlyModifiedAsset.java | 13 ++++++++-- .../cz/cvut/kbss/termit/dto/Snapshot.java | 9 ++++++- .../cz/cvut/kbss/termit/dto/TermInfo.java | 8 ++++++- .../dto/acl/AccessControlRecordDto.java | 6 ++++- .../assignment/ResourceTermOccurrences.java | 7 +++++- .../dto/assignment/TermOccurrences.java | 6 ++++- .../kbss/termit/dto/mapper/DtoMapper.java | 8 ++++++- .../termit/dto/readonly/ReadOnlyTerm.java | 13 ++++++++-- .../dto/search/FacetedSearchResult.java | 7 +++++- .../dto/search/FullTextSearchResult.java | 10 +++++++- .../termit/event/DocumentRenameEvent.java | 3 ++- .../java/cz/cvut/kbss/termit/model/User.java | 2 +- .../cvut/kbss/termit/model/UserAccount.java | 8 +++++-- .../cz/cvut/kbss/termit/model/UserGroup.java | 8 +++++-- .../termit/model/acl/AccessControlRecord.java | 6 ++++- .../model/assignment/OccurrenceTarget.java | 6 ++++- .../changetracking/AbstractChangeRecord.java | 6 ++++- .../kbss/termit/model/comment/Comment.java | 10 +++++++- .../cvut/kbss/termit/model/resource/File.java | 6 ++++- .../kbss/termit/model/resource/Resource.java | 2 +- .../termit/model/selector/CssSelector.java | 2 +- .../model/selector/FragmentSelector.java | 2 +- .../model/selector/TextPositionSelector.java | 2 +- .../model/selector/TextQuoteSelector.java | 2 +- .../termit/model/selector/XPathSelector.java | 2 +- .../validation/WithoutQueryParameters.java | 1 + .../WithoutQueryParametersValidator.java | 1 + .../model/validation/ValidationResult.java | 3 ++- .../persistence/MainPersistenceFactory.java | 11 ++++++--- .../kbss/termit/persistence/dao/DataDao.java | 2 +- .../termit/persistence/dao/SearchDao.java | 2 +- .../persistence/dao/UserAccountDao.java | 2 +- .../persistence/dao/skos/SKOSExporter.java | 12 ++++++++-- .../SparqlResultToTermOccurrenceMapper.java | 12 ++++++++-- .../validation/ResultCachingValidator.java | 7 +++++- .../persistence/validation/Validator.java | 7 ++++-- .../kbss/termit/rest/SnapshotController.java | 7 +++++- .../cvut/kbss/termit/rest/UserController.java | 11 ++++++++- .../kbss/termit/rest/UserGroupController.java | 10 +++++++- .../kbss/termit/rest/WorkspaceController.java | 7 +++++- .../rest/readonly/ReadOnlyTermController.java | 6 ++++- .../ReadOnlyVocabularyController.java | 6 ++++- .../servlet/DiagnosticsContextFilter.java | 6 ++--- .../cvut/kbss/termit/rest/util/RestUtils.java | 4 ++-- .../security/AuthenticationFailure.java | 4 ++-- .../security/AuthenticationSuccess.java | 4 ++-- .../security/JwtAuthenticationFilter.java | 8 +++---- .../security/model/TermItUserDetails.java | 11 +++++---- .../kbss/termit/security/model/UserRole.java | 1 + .../service/business/VocabularyService.java | 2 +- .../MetamodelBasedChangeCalculator.java | 16 +++++++++++-- .../document/DefaultDocumentManager.java | 11 +++++++-- .../service/document/TextAnalysisService.java | 6 ++++- .../export/ExcelVocabularyExporter.java | 2 +- .../kbss/termit/service/mail/Postman.java | 8 +++---- .../notification/CommentChangeNotifier.java | 16 +++++++++++-- .../notification/NotificationService.java | 5 +++- .../BaseAssetRepositoryService.java | 3 +-- .../repository/BaseRepositoryService.java | 2 +- .../RepositoryAccessControlListService.java | 6 ++++- .../repository/ResourceRepositoryService.java | 2 +- .../UserGroupRepositoryService.java | 2 +- .../repository/UserRepositoryService.java | 3 +-- .../util/AdjustedUriTemplateProxyServlet.java | 6 ++--- ...lingualStringPrimaryNotBlankValidator.java | 3 +-- .../termit/validation/PrimaryNotBlank.java | 8 ++++++- .../termit/validation/ValidationResult.java | 1 + .../termit/websocket/ResultWithHeaders.java | 2 +- .../websocket/VocabularySocketController.java | 2 -- .../workspace/EditableVocabularies.java | 4 +++- .../workspace/EditableVocabulariesHolder.java | 6 ++++- .../dto/assignment/TermOccurrencesTest.java | 2 +- .../kbss/termit/dto/mapper/DtoMapperTest.java | 6 ++++- .../termit/dto/readonly/ReadOnlyTermTest.java | 10 ++++++-- .../termit/dto/search/SearchParamTest.java | 2 +- .../environment/TestPersistenceFactory.java | 9 ++++--- .../exception/ValidationExceptionTest.java | 3 +-- .../cvut/kbss/termit/model/DocumentTest.java | 6 ++++- .../cz/cvut/kbss/termit/model/TermTest.java | 10 ++++++-- .../kbss/termit/model/UserAccountTest.java | 6 ++++- .../acl/RoleAccessControlRecordTest.java | 4 +++- .../acl/UserAccessControlRecordTest.java | 4 +++- .../acl/UserGroupAccessControlRecordTest.java | 4 +++- .../util/EntityToOwlClassMapperTest.java | 5 ++-- .../TextAnalysisRecordDaoTest.java | 4 +++- .../DefaultVocabularyContextMapperTest.java | 6 ++++- .../context/DescriptorFactoryTest.java | 4 +++- .../WorkspaceVocabularyContextMapperTest.java | 4 +++- .../termit/persistence/dao/AssetDaoTest.java | 10 ++++++-- .../persistence/dao/BaseAssetDaoTest.java | 3 ++- .../persistence/dao/SnapshotDaoTest.java | 4 +++- .../dao/TermDaoExactMatchTermsTest.java | 24 +++++++++---------- .../dao/TermDaoRelatedTermsTest.java | 18 +++++++++++--- .../persistence/dao/TermDaoSnapshotsTest.java | 11 +++++++-- .../dao/TermOccurrenceDaoTest.java | 22 +++++++++++++---- .../persistence/dao/UserAccountDaoTest.java | 5 +++- .../dao/acl/AccessControlListDaoTest.java | 16 ++++++++++--- .../changetracking/ChangeRecordDaoTest.java | 12 ++++++++-- .../ChangeTrackingContextResolverTest.java | 4 +++- .../ChangeTrackingHelperDaoTest.java | 5 +++- .../dao/comment/CommentReactionDaoTest.java | 4 +++- .../dao/lucene/LuceneSearchDaoTest.java | 7 +++++- .../SparqlResultToTermInfoMapperTest.java | 2 +- .../CascadingSnapshotCreatorTest.java | 5 +++- .../ResultCachingValidatorTest.java | 6 ++++- .../termit/rest/BaseControllerTestRunner.java | 5 +++- .../termit/rest/CommentControllerTest.java | 11 +++++++-- .../ConfigurationControllerSecurityTest.java | 4 +++- .../termit/rest/ResourceControllerTest.java | 2 +- .../kbss/termit/rest/TermControllerTest.java | 2 +- .../rest/UserControllerSecurityTest.java | 10 +++++--- .../kbss/termit/rest/UserControllerTest.java | 10 ++++++-- .../termit/rest/UserGroupControllerTest.java | 4 +++- .../VocabularyControllerSecurityTest.java | 4 +++- .../termit/rest/VocabularyControllerTest.java | 4 +--- .../servlet/DiagnosticsContextFilterTest.java | 10 ++++---- .../kbss/termit/rest/util/RestUtilsTest.java | 7 ++++-- .../security/AuthenticationFailureTest.java | 4 +++- .../security/AuthenticationSuccessTest.java | 5 +++- .../kbss/termit/security/JwtUtilsTest.java | 12 ++++++++-- ...OntologicalAuthenticationProviderTest.java | 11 +++++++-- .../security/model/TermItUserDetailsTest.java | 6 +++-- .../service/IdentifierResolverTest.java | 6 ++++- .../service/business/ResourceServiceTest.java | 2 +- .../service/business/SearchServiceTest.java | 10 ++++++-- .../service/business/TermServiceTest.java | 2 +- .../readonly/ReadOnlyTermServiceTest.java | 5 +++- .../ReadOnlyVocabularyServiceTest.java | 5 +++- .../MetamodelBasedChangeCalculatorTest.java | 8 +++++-- .../document/DefaultDocumentManagerTest.java | 7 +++++- .../document/TextAnalysisServiceTest.java | 19 ++++++++++++--- .../service/export/ExcelTermExporterTest.java | 8 ++++++- .../export/ExcelVocabularyExporterTest.java | 7 +++++- .../export/SKOSVocabularyExporterTest.java | 12 ++++++++-- .../init/AdminAccountGeneratorTest.java | 9 +++++-- ...abularyAccessControlListGeneratorTest.java | 11 +++++---- .../language/UfoTermTypesServiceTest.java | 4 +++- .../termit/service/mail/AssetLinkTest.java | 4 +++- .../CommentChangeNotifierTest.java | 15 +++++++++--- .../notification/NotificationServiceTest.java | 4 +++- .../BaseAssetRepositoryServiceImpl.java | 3 +-- .../BaseAssetRepositoryServiceTest.java | 6 +++-- .../repository/BaseRepositoryServiceImpl.java | 3 +-- .../repository/BaseRepositoryServiceTest.java | 16 ++++++++++--- ...epositoryAccessControlListServiceTest.java | 16 ++++++++++--- .../ResourceRepositoryServiceTest.java | 13 ++++++---- .../repository/UserRepositoryServiceTest.java | 15 ++++++++---- .../RuntimeBasedLoginTrackerTest.java | 9 +++++-- .../TermItUserDetailsServiceTest.java | 6 +++-- .../ResourceAuthorizationServiceTest.java | 4 +++- .../SnapshotAuthorizationServiceTest.java | 5 +++- .../VocabularyAuthorizationServiceTest.java | 10 ++++++-- ...trolListBasedAuthorizationServiceTest.java | 14 ++++++++--- ...sertedInferredValueDifferentiatorTest.java | 8 +++++-- ...thenticatingServletRequestWrapperTest.java | 4 +++- .../cvut/kbss/termit/util/CsvUtilsTest.java | 4 ++-- .../cz/cvut/kbss/termit/util/UtilsTest.java | 7 +++++- ...ualStringPrimaryNotBlankValidatorTest.java | 3 +-- .../workspace/EditableVocabulariesTest.java | 4 +++- 164 files changed, 827 insertions(+), 269 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/config/SessionTimeoutManager.java b/src/main/java/cz/cvut/kbss/termit/config/SessionTimeoutManager.java index e4896c0ca..51d7ec681 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/SessionTimeoutManager.java +++ b/src/main/java/cz/cvut/kbss/termit/config/SessionTimeoutManager.java @@ -18,12 +18,11 @@ package cz.cvut.kbss.termit.config; import cz.cvut.kbss.termit.security.SecurityConstants; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import jakarta.servlet.annotation.WebListener; import jakarta.servlet.http.HttpSessionEvent; import jakarta.servlet.http.HttpSessionListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @WebListener public class SessionTimeoutManager implements HttpSessionListener { diff --git a/src/main/java/cz/cvut/kbss/termit/dto/AggregatedChangeInfo.java b/src/main/java/cz/cvut/kbss/termit/dto/AggregatedChangeInfo.java index 6624d8fbd..c07bc314f 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/AggregatedChangeInfo.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/AggregatedChangeInfo.java @@ -17,7 +17,11 @@ */ package cz.cvut.kbss.termit.dto; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.ConstructorResult; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.SparqlResultSetMapping; +import cz.cvut.kbss.jopa.model.annotations.Types; +import cz.cvut.kbss.jopa.model.annotations.VariableResult; import cz.cvut.kbss.termit.model.util.HasTypes; import cz.cvut.kbss.termit.util.Vocabulary; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/ConfigurationDto.java b/src/main/java/cz/cvut/kbss/termit/dto/ConfigurationDto.java index d8c4cb821..8b4591fcc 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/ConfigurationDto.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/ConfigurationDto.java @@ -17,7 +17,11 @@ */ package cz.cvut.kbss.termit.dto; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.Id; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; import cz.cvut.kbss.jopa.model.annotations.util.NonEntity; import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.termit.model.UserRole; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/RdfsResource.java b/src/main/java/cz/cvut/kbss/termit/dto/RdfsResource.java index 9b21b85aa..43228b2a2 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/RdfsResource.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/RdfsResource.java @@ -18,7 +18,13 @@ package cz.cvut.kbss.termit.dto; import cz.cvut.kbss.jopa.model.MultilingualString; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.ConstructorResult; +import cz.cvut.kbss.jopa.model.annotations.Id; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.SparqlResultSetMapping; +import cz.cvut.kbss.jopa.model.annotations.Types; +import cz.cvut.kbss.jopa.model.annotations.VariableResult; import cz.cvut.kbss.jopa.vocabulary.RDFS; import cz.cvut.kbss.ontodriver.model.LangString; import cz.cvut.kbss.termit.model.util.HasIdentifier; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/RecentlyCommentedAsset.java b/src/main/java/cz/cvut/kbss/termit/dto/RecentlyCommentedAsset.java index 03419f92c..2e80ce4f0 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/RecentlyCommentedAsset.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/RecentlyCommentedAsset.java @@ -17,12 +17,20 @@ */ package cz.cvut.kbss.termit.dto; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.ConstructorResult; +import cz.cvut.kbss.jopa.model.annotations.Id; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.SparqlResultSetMapping; +import cz.cvut.kbss.jopa.model.annotations.Transient; +import cz.cvut.kbss.jopa.model.annotations.Types; +import cz.cvut.kbss.jopa.model.annotations.VariableResult; import cz.cvut.kbss.jopa.vocabulary.RDFS; import cz.cvut.kbss.termit.model.comment.Comment; import cz.cvut.kbss.termit.model.util.HasTypes; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.Vocabulary; + import java.io.Serializable; import java.net.URI; import java.util.Collections; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/RecentlyModifiedAsset.java b/src/main/java/cz/cvut/kbss/termit/dto/RecentlyModifiedAsset.java index e8f2afb9d..f0c6aa53d 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/RecentlyModifiedAsset.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/RecentlyModifiedAsset.java @@ -18,7 +18,13 @@ package cz.cvut.kbss.termit.dto; import com.fasterxml.jackson.annotation.JsonIgnore; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.ConstructorResult; +import cz.cvut.kbss.jopa.model.annotations.Id; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.SparqlResultSetMapping; +import cz.cvut.kbss.jopa.model.annotations.Types; +import cz.cvut.kbss.jopa.model.annotations.VariableResult; import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.jopa.vocabulary.RDFS; import cz.cvut.kbss.termit.model.User; @@ -29,7 +35,10 @@ import java.io.Serializable; import java.net.URI; import java.time.Instant; -import java.util.*; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; @SparqlResultSetMapping(name = "RecentlyModifiedAsset", classes = {@ConstructorResult(targetClass = RecentlyModifiedAsset.class, variables = { diff --git a/src/main/java/cz/cvut/kbss/termit/dto/Snapshot.java b/src/main/java/cz/cvut/kbss/termit/dto/Snapshot.java index ffd48370d..75b92277b 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/Snapshot.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/Snapshot.java @@ -17,7 +17,14 @@ */ package cz.cvut.kbss.termit.dto; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.ConstructorResult; +import cz.cvut.kbss.jopa.model.annotations.Id; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.SparqlResultSetMapping; +import cz.cvut.kbss.jopa.model.annotations.Types; +import cz.cvut.kbss.jopa.model.annotations.VariableResult; import cz.cvut.kbss.termit.model.util.HasIdentifier; import cz.cvut.kbss.termit.model.util.HasTypes; import cz.cvut.kbss.termit.util.Vocabulary; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/TermInfo.java b/src/main/java/cz/cvut/kbss/termit/dto/TermInfo.java index 8e966bc8b..37164b087 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/TermInfo.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/TermInfo.java @@ -18,7 +18,13 @@ package cz.cvut.kbss.termit.dto; import cz.cvut.kbss.jopa.model.MultilingualString; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.Id; +import cz.cvut.kbss.jopa.model.annotations.Inferred; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; +import cz.cvut.kbss.jopa.model.annotations.Types; import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.termit.model.AbstractTerm; import cz.cvut.kbss.termit.model.Term; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/acl/AccessControlRecordDto.java b/src/main/java/cz/cvut/kbss/termit/dto/acl/AccessControlRecordDto.java index d92f9853b..854c8b3ea 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/acl/AccessControlRecordDto.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/acl/AccessControlRecordDto.java @@ -17,7 +17,11 @@ */ package cz.cvut.kbss.termit.dto.acl; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.EnumType; +import cz.cvut.kbss.jopa.model.annotations.Enumerated; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.Types; import cz.cvut.kbss.jopa.model.annotations.util.NonEntity; import cz.cvut.kbss.termit.model.AbstractEntity; import cz.cvut.kbss.termit.model.acl.AccessLevel; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/assignment/ResourceTermOccurrences.java b/src/main/java/cz/cvut/kbss/termit/dto/assignment/ResourceTermOccurrences.java index b77431723..4a542919a 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/assignment/ResourceTermOccurrences.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/assignment/ResourceTermOccurrences.java @@ -17,7 +17,12 @@ */ package cz.cvut.kbss.termit.dto.assignment; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.ConstructorResult; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.SparqlResultSetMapping; +import cz.cvut.kbss.jopa.model.annotations.VariableResult; import cz.cvut.kbss.jopa.vocabulary.RDFS; import cz.cvut.kbss.termit.util.Vocabulary; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/assignment/TermOccurrences.java b/src/main/java/cz/cvut/kbss/termit/dto/assignment/TermOccurrences.java index 17d388961..87c5b702e 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/assignment/TermOccurrences.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/assignment/TermOccurrences.java @@ -18,7 +18,11 @@ package cz.cvut.kbss.termit.dto.assignment; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.ConstructorResult; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.SparqlResultSetMapping; +import cz.cvut.kbss.jopa.model.annotations.VariableResult; import cz.cvut.kbss.jopa.vocabulary.RDFS; import cz.cvut.kbss.termit.util.Vocabulary; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/mapper/DtoMapper.java b/src/main/java/cz/cvut/kbss/termit/dto/mapper/DtoMapper.java index 7dd68b02f..0757963dc 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/mapper/DtoMapper.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/mapper/DtoMapper.java @@ -25,7 +25,13 @@ import cz.cvut.kbss.termit.dto.acl.AccessHolderDto; import cz.cvut.kbss.termit.dto.listing.DocumentDto; import cz.cvut.kbss.termit.dto.listing.VocabularyDto; -import cz.cvut.kbss.termit.model.*; +import cz.cvut.kbss.termit.model.AccessControlAgent; +import cz.cvut.kbss.termit.model.Asset; +import cz.cvut.kbss.termit.model.PasswordChangeRequest; +import cz.cvut.kbss.termit.model.User; +import cz.cvut.kbss.termit.model.UserGroup; +import cz.cvut.kbss.termit.model.UserRole; +import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.acl.AccessControlList; import cz.cvut.kbss.termit.model.acl.AccessControlRecord; import cz.cvut.kbss.termit.model.resource.Document; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/readonly/ReadOnlyTerm.java b/src/main/java/cz/cvut/kbss/termit/dto/readonly/ReadOnlyTerm.java index cd9a2ac23..e43283c94 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/readonly/ReadOnlyTerm.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/readonly/ReadOnlyTerm.java @@ -18,15 +18,24 @@ package cz.cvut.kbss.termit.dto.readonly; import cz.cvut.kbss.jopa.model.MultilingualString; +import cz.cvut.kbss.jopa.model.annotations.FetchType; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; import cz.cvut.kbss.jopa.model.annotations.Properties; -import cz.cvut.kbss.jopa.model.annotations.*; import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.termit.dto.TermInfo; import cz.cvut.kbss.termit.model.AbstractTerm; import cz.cvut.kbss.termit.model.Term; -import java.util.*; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @OWLClass(iri = SKOS.CONCEPT) diff --git a/src/main/java/cz/cvut/kbss/termit/dto/search/FacetedSearchResult.java b/src/main/java/cz/cvut/kbss/termit/dto/search/FacetedSearchResult.java index cb54fdbc7..0fd546ca3 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/search/FacetedSearchResult.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/search/FacetedSearchResult.java @@ -18,7 +18,12 @@ package cz.cvut.kbss.termit.dto.search; import cz.cvut.kbss.jopa.model.MultilingualString; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.Inferred; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.Types; import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.util.HasTypes; diff --git a/src/main/java/cz/cvut/kbss/termit/dto/search/FullTextSearchResult.java b/src/main/java/cz/cvut/kbss/termit/dto/search/FullTextSearchResult.java index e0a6cdde5..c107aea5d 100644 --- a/src/main/java/cz/cvut/kbss/termit/dto/search/FullTextSearchResult.java +++ b/src/main/java/cz/cvut/kbss/termit/dto/search/FullTextSearchResult.java @@ -17,7 +17,15 @@ */ package cz.cvut.kbss.termit.dto.search; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.ConstructorResult; +import cz.cvut.kbss.jopa.model.annotations.Id; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; +import cz.cvut.kbss.jopa.model.annotations.SparqlResultSetMapping; +import cz.cvut.kbss.jopa.model.annotations.Types; +import cz.cvut.kbss.jopa.model.annotations.VariableResult; import cz.cvut.kbss.jopa.vocabulary.RDFS; import cz.cvut.kbss.termit.model.util.HasIdentifier; import cz.cvut.kbss.termit.model.util.HasTypes; diff --git a/src/main/java/cz/cvut/kbss/termit/event/DocumentRenameEvent.java b/src/main/java/cz/cvut/kbss/termit/event/DocumentRenameEvent.java index c6510df71..529f6456e 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/DocumentRenameEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/DocumentRenameEvent.java @@ -18,9 +18,10 @@ package cz.cvut.kbss.termit.event; import cz.cvut.kbss.termit.model.resource.Document; -import java.util.Objects; import org.springframework.context.ApplicationEvent; +import java.util.Objects; + /** * Indicates that a {@link Document} asset has changed its label. */ diff --git a/src/main/java/cz/cvut/kbss/termit/model/User.java b/src/main/java/cz/cvut/kbss/termit/model/User.java index 266d971df..c7a6ff5b3 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/User.java +++ b/src/main/java/cz/cvut/kbss/termit/model/User.java @@ -24,8 +24,8 @@ import cz.cvut.kbss.jopa.model.annotations.Types; import cz.cvut.kbss.termit.model.util.HasTypes; import cz.cvut.kbss.termit.util.Vocabulary; - import jakarta.validation.constraints.NotBlank; + import java.io.Serializable; import java.util.HashSet; import java.util.Objects; diff --git a/src/main/java/cz/cvut/kbss/termit/model/UserAccount.java b/src/main/java/cz/cvut/kbss/termit/model/UserAccount.java index 29b91d0dc..1de862f79 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/UserAccount.java +++ b/src/main/java/cz/cvut/kbss/termit/model/UserAccount.java @@ -18,13 +18,17 @@ package cz.cvut.kbss.termit.model; import com.fasterxml.jackson.annotation.JsonIgnore; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.Id; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; +import cz.cvut.kbss.jopa.model.annotations.Types; import cz.cvut.kbss.termit.model.util.HasIdentifier; import cz.cvut.kbss.termit.model.util.HasTypes; import cz.cvut.kbss.termit.security.model.UserRole; import cz.cvut.kbss.termit.util.Vocabulary; - import jakarta.validation.constraints.NotBlank; + import java.io.Serializable; import java.net.URI; import java.time.Instant; diff --git a/src/main/java/cz/cvut/kbss/termit/model/UserGroup.java b/src/main/java/cz/cvut/kbss/termit/model/UserGroup.java index 19caf4e7c..7ebcc52b8 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/UserGroup.java +++ b/src/main/java/cz/cvut/kbss/termit/model/UserGroup.java @@ -17,12 +17,16 @@ */ package cz.cvut.kbss.termit.model; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.FetchType; +import cz.cvut.kbss.jopa.model.annotations.OWLAnnotationProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.Vocabulary; - import jakarta.validation.constraints.NotBlank; + import java.util.HashSet; import java.util.Objects; import java.util.Set; diff --git a/src/main/java/cz/cvut/kbss/termit/model/acl/AccessControlRecord.java b/src/main/java/cz/cvut/kbss/termit/model/acl/AccessControlRecord.java index b9ff030c2..14020951e 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/acl/AccessControlRecord.java +++ b/src/main/java/cz/cvut/kbss/termit/model/acl/AccessControlRecord.java @@ -18,7 +18,11 @@ package cz.cvut.kbss.termit.model.acl; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.EnumType; +import cz.cvut.kbss.jopa.model.annotations.Enumerated; +import cz.cvut.kbss.jopa.model.annotations.FetchType; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; import cz.cvut.kbss.termit.model.AbstractEntity; import cz.cvut.kbss.termit.model.AccessControlAgent; import cz.cvut.kbss.termit.model.UserAccount; diff --git a/src/main/java/cz/cvut/kbss/termit/model/assignment/OccurrenceTarget.java b/src/main/java/cz/cvut/kbss/termit/model/assignment/OccurrenceTarget.java index a46dc56f2..1a695e734 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/assignment/OccurrenceTarget.java +++ b/src/main/java/cz/cvut/kbss/termit/model/assignment/OccurrenceTarget.java @@ -18,7 +18,11 @@ package cz.cvut.kbss.termit.model.assignment; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.CascadeType; +import cz.cvut.kbss.jopa.model.annotations.FetchType; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; import cz.cvut.kbss.termit.model.AbstractEntity; import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.selector.Selector; diff --git a/src/main/java/cz/cvut/kbss/termit/model/changetracking/AbstractChangeRecord.java b/src/main/java/cz/cvut/kbss/termit/model/changetracking/AbstractChangeRecord.java index 2a388df19..58d100b92 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/changetracking/AbstractChangeRecord.java +++ b/src/main/java/cz/cvut/kbss/termit/model/changetracking/AbstractChangeRecord.java @@ -18,7 +18,11 @@ package cz.cvut.kbss.termit.model.changetracking; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.FetchType; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; import cz.cvut.kbss.termit.model.AbstractEntity; import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.User; diff --git a/src/main/java/cz/cvut/kbss/termit/model/comment/Comment.java b/src/main/java/cz/cvut/kbss/termit/model/comment/Comment.java index e3731c8b4..6e73ab8f9 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/comment/Comment.java +++ b/src/main/java/cz/cvut/kbss/termit/model/comment/Comment.java @@ -17,7 +17,15 @@ */ package cz.cvut.kbss.termit.model.comment; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.CascadeType; +import cz.cvut.kbss.jopa.model.annotations.FetchType; +import cz.cvut.kbss.jopa.model.annotations.Inferred; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; +import cz.cvut.kbss.jopa.model.annotations.PrePersist; +import cz.cvut.kbss.jopa.model.annotations.PreUpdate; import cz.cvut.kbss.termit.model.AbstractEntity; import cz.cvut.kbss.termit.model.User; import cz.cvut.kbss.termit.util.Utils; diff --git a/src/main/java/cz/cvut/kbss/termit/model/resource/File.java b/src/main/java/cz/cvut/kbss/termit/model/resource/File.java index 96cadfe60..26b45f940 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/resource/File.java +++ b/src/main/java/cz/cvut/kbss/termit/model/resource/File.java @@ -19,7 +19,11 @@ import com.fasterxml.jackson.annotation.JsonBackReference; import com.fasterxml.jackson.annotation.JsonIgnore; -import cz.cvut.kbss.jopa.model.annotations.*; +import cz.cvut.kbss.jopa.model.annotations.FetchType; +import cz.cvut.kbss.jopa.model.annotations.Inferred; +import cz.cvut.kbss.jopa.model.annotations.OWLClass; +import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; +import cz.cvut.kbss.jopa.model.annotations.Types; import cz.cvut.kbss.jsonld.annotation.JsonLdAttributeOrder; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.util.SupportsStorage; diff --git a/src/main/java/cz/cvut/kbss/termit/model/resource/Resource.java b/src/main/java/cz/cvut/kbss/termit/model/resource/Resource.java index a29612c5d..f10dd9d36 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/resource/Resource.java +++ b/src/main/java/cz/cvut/kbss/termit/model/resource/Resource.java @@ -28,8 +28,8 @@ import cz.cvut.kbss.termit.model.changetracking.Audited; import cz.cvut.kbss.termit.model.util.AssetVisitor; import cz.cvut.kbss.termit.util.Vocabulary; - import jakarta.validation.constraints.NotBlank; + import java.io.Serializable; import java.util.Objects; diff --git a/src/main/java/cz/cvut/kbss/termit/model/selector/CssSelector.java b/src/main/java/cz/cvut/kbss/termit/model/selector/CssSelector.java index 740fb60e5..af81ac89d 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/selector/CssSelector.java +++ b/src/main/java/cz/cvut/kbss/termit/model/selector/CssSelector.java @@ -22,8 +22,8 @@ import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; import cz.cvut.kbss.jopa.vocabulary.RDF; import cz.cvut.kbss.termit.util.Vocabulary; - import jakarta.validation.constraints.NotBlank; + import java.util.Objects; @OWLClass(iri = Vocabulary.s_c_selektor_css) diff --git a/src/main/java/cz/cvut/kbss/termit/model/selector/FragmentSelector.java b/src/main/java/cz/cvut/kbss/termit/model/selector/FragmentSelector.java index 55cb77191..3c97fab18 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/selector/FragmentSelector.java +++ b/src/main/java/cz/cvut/kbss/termit/model/selector/FragmentSelector.java @@ -22,8 +22,8 @@ import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; import cz.cvut.kbss.jopa.vocabulary.RDF; import cz.cvut.kbss.termit.util.Vocabulary; - import jakarta.validation.constraints.NotBlank; + import java.util.Objects; @OWLClass(iri = Vocabulary.s_c_selektor_fragmentem) diff --git a/src/main/java/cz/cvut/kbss/termit/model/selector/TextPositionSelector.java b/src/main/java/cz/cvut/kbss/termit/model/selector/TextPositionSelector.java index 538d9dd87..0d5bfb66a 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/selector/TextPositionSelector.java +++ b/src/main/java/cz/cvut/kbss/termit/model/selector/TextPositionSelector.java @@ -21,8 +21,8 @@ import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; import cz.cvut.kbss.termit.util.Vocabulary; - import jakarta.validation.constraints.NotNull; + import java.util.Objects; /** diff --git a/src/main/java/cz/cvut/kbss/termit/model/selector/TextQuoteSelector.java b/src/main/java/cz/cvut/kbss/termit/model/selector/TextQuoteSelector.java index c1568019a..879374ca2 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/selector/TextQuoteSelector.java +++ b/src/main/java/cz/cvut/kbss/termit/model/selector/TextQuoteSelector.java @@ -21,8 +21,8 @@ import cz.cvut.kbss.jopa.model.annotations.OWLDataProperty; import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; import cz.cvut.kbss.termit.util.Vocabulary; - import jakarta.validation.constraints.NotBlank; + import java.util.Objects; import static cz.cvut.kbss.termit.util.Utils.trim; diff --git a/src/main/java/cz/cvut/kbss/termit/model/selector/XPathSelector.java b/src/main/java/cz/cvut/kbss/termit/model/selector/XPathSelector.java index d7097a5e4..1e3b8192a 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/selector/XPathSelector.java +++ b/src/main/java/cz/cvut/kbss/termit/model/selector/XPathSelector.java @@ -22,8 +22,8 @@ import cz.cvut.kbss.jopa.model.annotations.ParticipationConstraints; import cz.cvut.kbss.jopa.vocabulary.RDF; import cz.cvut.kbss.termit.util.Vocabulary; - import jakarta.validation.constraints.NotBlank; + import java.util.Objects; @OWLClass(iri = Vocabulary.s_c_selektor_xpath) diff --git a/src/main/java/cz/cvut/kbss/termit/model/util/validation/WithoutQueryParameters.java b/src/main/java/cz/cvut/kbss/termit/model/util/validation/WithoutQueryParameters.java index 1aeb67493..11a4c75f0 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/util/validation/WithoutQueryParameters.java +++ b/src/main/java/cz/cvut/kbss/termit/model/util/validation/WithoutQueryParameters.java @@ -19,6 +19,7 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; diff --git a/src/main/java/cz/cvut/kbss/termit/model/util/validation/WithoutQueryParametersValidator.java b/src/main/java/cz/cvut/kbss/termit/model/util/validation/WithoutQueryParametersValidator.java index 766680e08..54013a254 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/util/validation/WithoutQueryParametersValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/model/util/validation/WithoutQueryParametersValidator.java @@ -19,6 +19,7 @@ import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; + import java.net.URI; /** diff --git a/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java b/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java index 5f6c81068..331bc461b 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java +++ b/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java @@ -24,9 +24,10 @@ import cz.cvut.kbss.jopa.model.annotations.OWLObjectProperty; import cz.cvut.kbss.jopa.model.annotations.util.NonEntity; import cz.cvut.kbss.termit.model.Term; +import org.topbraid.shacl.vocabulary.SH; + import java.net.URI; import java.util.Objects; -import org.topbraid.shacl.vocabulary.SH; @NonEntity @OWLClass(iri = SH.BASE_URI + "ValidationResult") diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/MainPersistenceFactory.java b/src/main/java/cz/cvut/kbss/termit/persistence/MainPersistenceFactory.java index 95cca9f18..250ea0b15 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/MainPersistenceFactory.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/MainPersistenceFactory.java @@ -23,6 +23,8 @@ import cz.cvut.kbss.ontodriver.config.OntoDriverProperties; import cz.cvut.kbss.ontodriver.rdf4j.config.Rdf4jOntoDriverProperties; import cz.cvut.kbss.termit.event.EvictCacheEvent; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -30,12 +32,15 @@ import org.springframework.context.annotation.Profile; import org.springframework.context.event.EventListener; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.HashMap; import java.util.Map; -import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.*; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.DATA_SOURCE_CLASS; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.JPA_PERSISTENCE_PROVIDER; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.LANG; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.ONTOLOGY_PHYSICAL_URI_KEY; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.PREFER_MULTILINGUAL_STRING; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.SCAN_PACKAGE; /** * Sets up persistence and provides {@link EntityManagerFactory} as Spring bean. diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java index c09d758dd..6ccb9786a 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/DataDao.java @@ -28,9 +28,9 @@ import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.persistence.dao.util.Quad; import cz.cvut.kbss.termit.service.export.ExportFormat; -import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Configuration.Persistence; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import jakarta.annotation.Nullable; import org.eclipse.rdf4j.model.Resource; diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/SearchDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/SearchDao.java index c4ca2a08d..8b6c0bffa 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/SearchDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/SearchDao.java @@ -26,6 +26,7 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.Vocabulary; +import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -34,7 +35,6 @@ import org.springframework.lang.NonNull; import org.springframework.stereotype.Repository; -import jakarta.annotation.PostConstruct; import java.net.URI; import java.util.Collection; import java.util.Collections; diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/UserAccountDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/UserAccountDao.java index 1a9fc53db..dd06ca16d 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/UserAccountDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/UserAccountDao.java @@ -22,8 +22,8 @@ import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.model.UserAccount; import cz.cvut.kbss.termit.util.Configuration; -import cz.cvut.kbss.termit.util.Vocabulary; import cz.cvut.kbss.termit.util.Configuration.Persistence; +import cz.cvut.kbss.termit.util.Vocabulary; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSExporter.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSExporter.java index d8995cdee..929edece9 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSExporter.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/skos/SKOSExporter.java @@ -25,8 +25,16 @@ import org.eclipse.rdf4j.model.Model; import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.impl.LinkedHashModel; -import org.eclipse.rdf4j.model.vocabulary.*; -import org.eclipse.rdf4j.query.*; +import org.eclipse.rdf4j.model.vocabulary.DCTERMS; +import org.eclipse.rdf4j.model.vocabulary.OWL; +import org.eclipse.rdf4j.model.vocabulary.RDF; +import org.eclipse.rdf4j.model.vocabulary.RDFS; +import org.eclipse.rdf4j.model.vocabulary.SKOS; +import org.eclipse.rdf4j.query.BindingSet; +import org.eclipse.rdf4j.query.GraphQuery; +import org.eclipse.rdf4j.query.GraphQueryResult; +import org.eclipse.rdf4j.query.TupleQuery; +import org.eclipse.rdf4j.query.TupleQueryResult; import org.eclipse.rdf4j.repository.RepositoryConnection; import org.eclipse.rdf4j.rio.RDFWriter; import org.eclipse.rdf4j.rio.Rio; diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/SparqlResultToTermOccurrenceMapper.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/SparqlResultToTermOccurrenceMapper.java index 358895a49..55acedcdd 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/SparqlResultToTermOccurrenceMapper.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/SparqlResultToTermOccurrenceMapper.java @@ -17,13 +17,21 @@ */ package cz.cvut.kbss.termit.persistence.dao.util; -import cz.cvut.kbss.termit.model.assignment.*; +import cz.cvut.kbss.termit.model.assignment.DefinitionalOccurrenceTarget; +import cz.cvut.kbss.termit.model.assignment.FileOccurrenceTarget; +import cz.cvut.kbss.termit.model.assignment.TermDefinitionalOccurrence; +import cz.cvut.kbss.termit.model.assignment.TermFileOccurrence; +import cz.cvut.kbss.termit.model.assignment.TermOccurrence; import cz.cvut.kbss.termit.model.selector.TextPositionSelector; import cz.cvut.kbss.termit.model.selector.TextQuoteSelector; import cz.cvut.kbss.termit.util.Vocabulary; import java.net.URI; -import java.util.*; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; /** * Maps SPARQL query results to {@link TermOccurrence} instances. diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 0d25c8b6c..eb757ccfd 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -28,7 +28,12 @@ import org.springframework.stereotype.Component; import java.net.URI; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @Component("cachingValidator") diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java index 63b0689cc..80775c3b9 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java @@ -25,7 +25,6 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper; import cz.cvut.kbss.termit.util.Configuration; -import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Utils; import org.apache.jena.rdf.model.Literal; import org.apache.jena.rdf.model.Model; @@ -52,7 +51,11 @@ import java.net.URI; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.stream.Collectors; @Component diff --git a/src/main/java/cz/cvut/kbss/termit/rest/SnapshotController.java b/src/main/java/cz/cvut/kbss/termit/rest/SnapshotController.java index 4d95c39cf..34f164e8a 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/SnapshotController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/SnapshotController.java @@ -29,7 +29,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; import java.net.URI; diff --git a/src/main/java/cz/cvut/kbss/termit/rest/UserController.java b/src/main/java/cz/cvut/kbss/termit/rest/UserController.java index 4f713eba8..96ea06491 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/UserController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/UserController.java @@ -39,7 +39,16 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; import java.net.URI; import java.util.List; diff --git a/src/main/java/cz/cvut/kbss/termit/rest/UserGroupController.java b/src/main/java/cz/cvut/kbss/termit/rest/UserGroupController.java index fa5df36c8..bb7ab0b78 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/UserGroupController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/UserGroupController.java @@ -36,7 +36,15 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; import java.net.URI; import java.util.List; diff --git a/src/main/java/cz/cvut/kbss/termit/rest/WorkspaceController.java b/src/main/java/cz/cvut/kbss/termit/rest/WorkspaceController.java index f9341f2ec..1c01a5157 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/WorkspaceController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/WorkspaceController.java @@ -29,7 +29,12 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; import java.net.URI; import java.util.Collection; diff --git a/src/main/java/cz/cvut/kbss/termit/rest/readonly/ReadOnlyTermController.java b/src/main/java/cz/cvut/kbss/termit/rest/readonly/ReadOnlyTermController.java index 75c0cfe9b..e256797e3 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/readonly/ReadOnlyTermController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/readonly/ReadOnlyTermController.java @@ -41,7 +41,11 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.net.URI; import java.time.Instant; diff --git a/src/main/java/cz/cvut/kbss/termit/rest/readonly/ReadOnlyVocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/readonly/ReadOnlyVocabularyController.java index cd3cd2efc..d3bbc64c9 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/readonly/ReadOnlyVocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/readonly/ReadOnlyVocabularyController.java @@ -35,7 +35,11 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.net.URI; import java.time.Instant; diff --git a/src/main/java/cz/cvut/kbss/termit/rest/servlet/DiagnosticsContextFilter.java b/src/main/java/cz/cvut/kbss/termit/rest/servlet/DiagnosticsContextFilter.java index 8b64e90dd..5b10b6f13 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/servlet/DiagnosticsContextFilter.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/servlet/DiagnosticsContextFilter.java @@ -17,14 +17,14 @@ */ package cz.cvut.kbss.termit.rest.servlet; -import org.slf4j.MDC; -import org.springframework.web.filter.GenericFilterBean; - import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.web.filter.GenericFilterBean; + import java.io.IOException; import java.security.Principal; diff --git a/src/main/java/cz/cvut/kbss/termit/rest/util/RestUtils.java b/src/main/java/cz/cvut/kbss/termit/rest/util/RestUtils.java index f59f8b82e..987982de8 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/util/RestUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/util/RestUtils.java @@ -18,14 +18,14 @@ package cz.cvut.kbss.termit.rest.util; import cz.cvut.kbss.termit.util.Constants; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; import java.net.URI; import java.time.Instant; import java.time.format.DateTimeParseException; diff --git a/src/main/java/cz/cvut/kbss/termit/security/AuthenticationFailure.java b/src/main/java/cz/cvut/kbss/termit/security/AuthenticationFailure.java index e40c998c5..1d55de2c6 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/AuthenticationFailure.java +++ b/src/main/java/cz/cvut/kbss/termit/security/AuthenticationFailure.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import cz.cvut.kbss.termit.security.model.LoginStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -30,8 +32,6 @@ import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Service; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; /** diff --git a/src/main/java/cz/cvut/kbss/termit/security/AuthenticationSuccess.java b/src/main/java/cz/cvut/kbss/termit/security/AuthenticationSuccess.java index 80a1656cf..17bfe45fd 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/AuthenticationSuccess.java +++ b/src/main/java/cz/cvut/kbss/termit/security/AuthenticationSuccess.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import cz.cvut.kbss.termit.security.model.LoginStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -30,8 +32,6 @@ import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import org.springframework.stereotype.Service; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Objects; diff --git a/src/main/java/cz/cvut/kbss/termit/security/JwtAuthenticationFilter.java b/src/main/java/cz/cvut/kbss/termit/security/JwtAuthenticationFilter.java index 2557c0c0d..5b5438b30 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/JwtAuthenticationFilter.java +++ b/src/main/java/cz/cvut/kbss/termit/security/JwtAuthenticationFilter.java @@ -18,15 +18,15 @@ package cz.cvut.kbss.termit.security; import cz.cvut.kbss.termit.security.model.TermItUserDetails; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; /** diff --git a/src/main/java/cz/cvut/kbss/termit/security/model/TermItUserDetails.java b/src/main/java/cz/cvut/kbss/termit/security/model/TermItUserDetails.java index c61b73227..603fb7752 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/model/TermItUserDetails.java +++ b/src/main/java/cz/cvut/kbss/termit/security/model/TermItUserDetails.java @@ -17,17 +17,20 @@ */ package cz.cvut.kbss.termit.security.model; -import static cz.cvut.kbss.termit.security.SecurityConstants.ROLE_RESTRICTED_USER; - - import cz.cvut.kbss.termit.model.UserAccount; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.util.*; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; +import static cz.cvut.kbss.termit.security.SecurityConstants.ROLE_RESTRICTED_USER; + public class TermItUserDetails implements UserDetails { /** diff --git a/src/main/java/cz/cvut/kbss/termit/security/model/UserRole.java b/src/main/java/cz/cvut/kbss/termit/security/model/UserRole.java index 6d1048156..e302ba6a1 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/model/UserRole.java +++ b/src/main/java/cz/cvut/kbss/termit/security/model/UserRole.java @@ -18,6 +18,7 @@ package cz.cvut.kbss.termit.security.model; import cz.cvut.kbss.termit.util.Vocabulary; + import java.util.Arrays; import java.util.HashSet; import java.util.Set; diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index ba7507daa..69a2dfc22 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -36,7 +36,6 @@ import cz.cvut.kbss.termit.persistence.snapshot.SnapshotCreator; import cz.cvut.kbss.termit.service.business.async.AsyncTermService; import cz.cvut.kbss.termit.service.changetracking.ChangeRecordProvider; -import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; @@ -44,6 +43,7 @@ import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.TypeAwareClasspathResource; +import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; diff --git a/src/main/java/cz/cvut/kbss/termit/service/changetracking/MetamodelBasedChangeCalculator.java b/src/main/java/cz/cvut/kbss/termit/service/changetracking/MetamodelBasedChangeCalculator.java index 9e89a872e..2961b75a4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/changetracking/MetamodelBasedChangeCalculator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/changetracking/MetamodelBasedChangeCalculator.java @@ -18,7 +18,12 @@ package cz.cvut.kbss.termit.service.changetracking; import cz.cvut.kbss.jopa.model.EntityManagerFactory; -import cz.cvut.kbss.jopa.model.metamodel.*; +import cz.cvut.kbss.jopa.model.metamodel.Attribute; +import cz.cvut.kbss.jopa.model.metamodel.EntityType; +import cz.cvut.kbss.jopa.model.metamodel.Metamodel; +import cz.cvut.kbss.jopa.model.metamodel.PluralAttribute; +import cz.cvut.kbss.jopa.model.metamodel.PropertiesSpecification; +import cz.cvut.kbss.jopa.model.metamodel.TypesSpecification; import cz.cvut.kbss.jopa.utils.EntityPropertiesUtils; import cz.cvut.kbss.jopa.utils.IdentifierTransformer; import cz.cvut.kbss.jopa.vocabulary.RDF; @@ -29,7 +34,14 @@ import org.springframework.stereotype.Component; import java.net.URI; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import static cz.cvut.kbss.jopa.utils.EntityPropertiesUtils.getAttributeValue; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManager.java b/src/main/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManager.java index 78125ef2f..6bf418349 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManager.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManager.java @@ -26,8 +26,8 @@ import cz.cvut.kbss.termit.model.resource.File; import cz.cvut.kbss.termit.model.resource.Resource; import cz.cvut.kbss.termit.service.IdentifierResolver; -import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.Utils; import org.apache.tika.Tika; @@ -46,7 +46,14 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; /** diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java index c5f81ba36..dbc94dfaf 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java @@ -29,7 +29,11 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; -import org.springframework.http.*; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; diff --git a/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java b/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java index c15896df4..c5d262e7d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporter.java @@ -24,10 +24,10 @@ import cz.cvut.kbss.termit.exception.UnsupportedOperationException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import cz.cvut.kbss.termit.util.Constants; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.Utils; import org.apache.poi.ss.usermodel.Cell; diff --git a/src/main/java/cz/cvut/kbss/termit/service/mail/Postman.java b/src/main/java/cz/cvut/kbss/termit/service/mail/Postman.java index eb7a07c11..cb66781e9 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/mail/Postman.java +++ b/src/main/java/cz/cvut/kbss/termit/service/mail/Postman.java @@ -19,6 +19,9 @@ import cz.cvut.kbss.termit.exception.PostmanException; import cz.cvut.kbss.termit.exception.ValidationException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -29,14 +32,9 @@ import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.InternetAddress; -import jakarta.mail.internet.MimeMessage; - import javax.annotation.PostConstruct; import java.io.UnsupportedEncodingException; import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.Objects; @Service diff --git a/src/main/java/cz/cvut/kbss/termit/service/notification/CommentChangeNotifier.java b/src/main/java/cz/cvut/kbss/termit/service/notification/CommentChangeNotifier.java index 70711e64b..38686d556 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/notification/CommentChangeNotifier.java +++ b/src/main/java/cz/cvut/kbss/termit/service/notification/CommentChangeNotifier.java @@ -17,7 +17,11 @@ */ package cz.cvut.kbss.termit.service.notification; -import cz.cvut.kbss.termit.model.*; +import cz.cvut.kbss.termit.model.Asset; +import cz.cvut.kbss.termit.model.Term; +import cz.cvut.kbss.termit.model.User; +import cz.cvut.kbss.termit.model.UserAccount; +import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.comment.Comment; import cz.cvut.kbss.termit.service.business.TermService; import cz.cvut.kbss.termit.service.business.UserService; @@ -35,7 +39,15 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; @Component diff --git a/src/main/java/cz/cvut/kbss/termit/service/notification/NotificationService.java b/src/main/java/cz/cvut/kbss/termit/service/notification/NotificationService.java index cfd67ee26..aa440e8b8 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/notification/NotificationService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/notification/NotificationService.java @@ -27,7 +27,10 @@ import org.springframework.scheduling.support.CronExpression; import org.springframework.stereotype.Service; -import java.time.*; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.Optional; @Service diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryService.java index a2e87dc80..e3c45527c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryService.java @@ -23,11 +23,10 @@ import cz.cvut.kbss.termit.model.User; import cz.cvut.kbss.termit.model.util.HasIdentifier; import cz.cvut.kbss.termit.persistence.dao.BaseAssetDao; +import jakarta.validation.Validator; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import jakarta.validation.Validator; - /** * Base repository service implementation for asset managing services. * diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java index a5015182d..be5bcceae 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java @@ -24,10 +24,10 @@ import cz.cvut.kbss.termit.model.util.HasIdentifier; import cz.cvut.kbss.termit.persistence.dao.GenericDao; import cz.cvut.kbss.termit.validation.ValidationResult; +import jakarta.validation.Validator; import org.springframework.lang.NonNull; import org.springframework.transaction.annotation.Transactional; -import jakarta.validation.Validator; import java.net.URI; import java.util.List; import java.util.Objects; diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListService.java index e05d05df2..6598bfaaa 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListService.java @@ -24,7 +24,11 @@ import cz.cvut.kbss.termit.model.AccessControlAgent; import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.UserRole; -import cz.cvut.kbss.termit.model.acl.*; +import cz.cvut.kbss.termit.model.acl.AccessControlList; +import cz.cvut.kbss.termit.model.acl.AccessControlRecord; +import cz.cvut.kbss.termit.model.acl.AccessLevel; +import cz.cvut.kbss.termit.model.acl.RoleAccessControlRecord; +import cz.cvut.kbss.termit.model.acl.UserAccessControlRecord; import cz.cvut.kbss.termit.model.util.HasIdentifier; import cz.cvut.kbss.termit.persistence.dao.acl.AccessControlListDao; import cz.cvut.kbss.termit.service.business.AccessControlListService; diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryService.java index 4bdd88dad..5f0b9bb61 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryService.java @@ -27,6 +27,7 @@ import cz.cvut.kbss.termit.persistence.dao.TermOccurrenceDao; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; +import jakarta.validation.Validator; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,7 +35,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import jakarta.validation.Validator; import java.util.Objects; @Service diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/UserGroupRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/UserGroupRepositoryService.java index 01130a855..146c83d60 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/UserGroupRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/UserGroupRepositoryService.java @@ -24,6 +24,7 @@ import cz.cvut.kbss.termit.security.SecurityConstants; import cz.cvut.kbss.termit.service.business.UserGroupService; import cz.cvut.kbss.termit.util.Utils; +import jakarta.validation.Validator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.lang.NonNull; @@ -31,7 +32,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import jakarta.validation.Validator; import java.net.URI; import java.util.Collection; import java.util.HashSet; diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/UserRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/UserRepositoryService.java index 52eeefdba..c98582bfb 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/UserRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/UserRepositoryService.java @@ -23,13 +23,12 @@ import cz.cvut.kbss.termit.persistence.dao.UserAccountDao; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; +import jakarta.validation.Validator; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import jakarta.validation.Validator; - import java.util.Optional; @Service diff --git a/src/main/java/cz/cvut/kbss/termit/util/AdjustedUriTemplateProxyServlet.java b/src/main/java/cz/cvut/kbss/termit/util/AdjustedUriTemplateProxyServlet.java index 8b46e3ce0..2355a18ea 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/AdjustedUriTemplateProxyServlet.java +++ b/src/main/java/cz/cvut/kbss/termit/util/AdjustedUriTemplateProxyServlet.java @@ -17,14 +17,14 @@ */ package cz.cvut.kbss.termit.util; -import org.mitre.dsmiley.httpproxy.URITemplateProxyServlet; -import org.springframework.http.HttpHeaders; - import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponseWrapper; +import org.mitre.dsmiley.httpproxy.URITemplateProxyServlet; +import org.springframework.http.HttpHeaders; + import java.io.IOException; import java.util.Base64; import java.util.Collections; diff --git a/src/main/java/cz/cvut/kbss/termit/validation/MultilingualStringPrimaryNotBlankValidator.java b/src/main/java/cz/cvut/kbss/termit/validation/MultilingualStringPrimaryNotBlankValidator.java index 4322bef9a..180ad175e 100644 --- a/src/main/java/cz/cvut/kbss/termit/validation/MultilingualStringPrimaryNotBlankValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/validation/MultilingualStringPrimaryNotBlankValidator.java @@ -20,10 +20,9 @@ import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Configuration.Persistence; -import org.springframework.beans.factory.annotation.Autowired; - import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; +import org.springframework.beans.factory.annotation.Autowired; /** * Validates that a {@link MultilingualString} contains translation in the primary language. diff --git a/src/main/java/cz/cvut/kbss/termit/validation/PrimaryNotBlank.java b/src/main/java/cz/cvut/kbss/termit/validation/PrimaryNotBlank.java index c22f49d47..b180115d3 100644 --- a/src/main/java/cz/cvut/kbss/termit/validation/PrimaryNotBlank.java +++ b/src/main/java/cz/cvut/kbss/termit/validation/PrimaryNotBlank.java @@ -19,7 +19,13 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import java.lang.annotation.*; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; /** * Validation constraint ensuring that a {@link cz.cvut.kbss.jopa.model.MultilingualString} contains a non-empty value diff --git a/src/main/java/cz/cvut/kbss/termit/validation/ValidationResult.java b/src/main/java/cz/cvut/kbss/termit/validation/ValidationResult.java index 9dc5fc21e..82de3929c 100644 --- a/src/main/java/cz/cvut/kbss/termit/validation/ValidationResult.java +++ b/src/main/java/cz/cvut/kbss/termit/validation/ValidationResult.java @@ -18,6 +18,7 @@ package cz.cvut.kbss.termit.validation; import jakarta.validation.ConstraintViolation; + import java.io.Serializable; import java.util.Collection; diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java b/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java index 1ec5c0ef6..8d74eeb72 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java @@ -1,10 +1,10 @@ package cz.cvut.kbss.termit.websocket; import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; -import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; import org.springframework.messaging.simp.annotation.SendToUser; import java.util.Collections; diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index 67c1af291..7e49b35e0 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -8,8 +8,6 @@ import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.MessageMapping; diff --git a/src/main/java/cz/cvut/kbss/termit/workspace/EditableVocabularies.java b/src/main/java/cz/cvut/kbss/termit/workspace/EditableVocabularies.java index 67fd3ab5c..f05b3e70c 100644 --- a/src/main/java/cz/cvut/kbss/termit/workspace/EditableVocabularies.java +++ b/src/main/java/cz/cvut/kbss/termit/workspace/EditableVocabularies.java @@ -26,7 +26,9 @@ import java.io.Serializable; import java.net.URI; -import java.util.*; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import static cz.cvut.kbss.termit.util.Utils.uriToString; diff --git a/src/main/java/cz/cvut/kbss/termit/workspace/EditableVocabulariesHolder.java b/src/main/java/cz/cvut/kbss/termit/workspace/EditableVocabulariesHolder.java index a8c801732..adb4bae0d 100644 --- a/src/main/java/cz/cvut/kbss/termit/workspace/EditableVocabulariesHolder.java +++ b/src/main/java/cz/cvut/kbss/termit/workspace/EditableVocabulariesHolder.java @@ -20,7 +20,11 @@ import org.springframework.web.context.annotation.SessionScope; import java.net.URI; -import java.util.*; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; /** * Holds editable vocabularies for a session (if applicable). diff --git a/src/test/java/cz/cvut/kbss/termit/dto/assignment/TermOccurrencesTest.java b/src/test/java/cz/cvut/kbss/termit/dto/assignment/TermOccurrencesTest.java index 3b6ebde88..f572462d3 100644 --- a/src/test/java/cz/cvut/kbss/termit/dto/assignment/TermOccurrencesTest.java +++ b/src/test/java/cz/cvut/kbss/termit/dto/assignment/TermOccurrencesTest.java @@ -26,7 +26,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertNotNull; class TermOccurrencesTest { diff --git a/src/test/java/cz/cvut/kbss/termit/dto/mapper/DtoMapperTest.java b/src/test/java/cz/cvut/kbss/termit/dto/mapper/DtoMapperTest.java index f2e1ce4b1..49ac12c88 100644 --- a/src/test/java/cz/cvut/kbss/termit/dto/mapper/DtoMapperTest.java +++ b/src/test/java/cz/cvut/kbss/termit/dto/mapper/DtoMapperTest.java @@ -22,7 +22,11 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.model.UserRole; -import cz.cvut.kbss.termit.model.acl.*; +import cz.cvut.kbss.termit.model.acl.AccessControlRecord; +import cz.cvut.kbss.termit.model.acl.AccessLevel; +import cz.cvut.kbss.termit.model.acl.RoleAccessControlRecord; +import cz.cvut.kbss.termit.model.acl.UserAccessControlRecord; +import cz.cvut.kbss.termit.model.acl.UserGroupAccessControlRecord; import cz.cvut.kbss.termit.util.Vocabulary; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; diff --git a/src/test/java/cz/cvut/kbss/termit/dto/readonly/ReadOnlyTermTest.java b/src/test/java/cz/cvut/kbss/termit/dto/readonly/ReadOnlyTermTest.java index 6709a8672..f6fb0d447 100644 --- a/src/test/java/cz/cvut/kbss/termit/dto/readonly/ReadOnlyTermTest.java +++ b/src/test/java/cz/cvut/kbss/termit/dto/readonly/ReadOnlyTermTest.java @@ -26,9 +26,15 @@ import org.junit.jupiter.api.Test; import java.net.URI; -import java.util.*; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class ReadOnlyTermTest { diff --git a/src/test/java/cz/cvut/kbss/termit/dto/search/SearchParamTest.java b/src/test/java/cz/cvut/kbss/termit/dto/search/SearchParamTest.java index b3e256c5c..811bf9cf0 100644 --- a/src/test/java/cz/cvut/kbss/termit/dto/search/SearchParamTest.java +++ b/src/test/java/cz/cvut/kbss/termit/dto/search/SearchParamTest.java @@ -25,7 +25,7 @@ import java.net.URI; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; class SearchParamTest { diff --git a/src/test/java/cz/cvut/kbss/termit/environment/TestPersistenceFactory.java b/src/test/java/cz/cvut/kbss/termit/environment/TestPersistenceFactory.java index affbb3859..978703159 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/TestPersistenceFactory.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/TestPersistenceFactory.java @@ -21,6 +21,8 @@ import cz.cvut.kbss.jopa.model.EntityManagerFactory; import cz.cvut.kbss.ontodriver.rdf4j.config.Rdf4jOntoDriverProperties; import cz.cvut.kbss.termit.persistence.MainPersistenceFactory; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -28,11 +30,12 @@ import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.Map; -import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.*; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.DATA_SOURCE_CLASS; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.LANG; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.ONTOLOGY_PHYSICAL_URI_KEY; +import static cz.cvut.kbss.jopa.model.JOPAPersistenceProperties.PREFER_MULTILINGUAL_STRING; @Configuration @EnableConfigurationProperties(cz.cvut.kbss.termit.util.Configuration.class) diff --git a/src/test/java/cz/cvut/kbss/termit/exception/ValidationExceptionTest.java b/src/test/java/cz/cvut/kbss/termit/exception/ValidationExceptionTest.java index 904bf20a6..611b05a38 100644 --- a/src/test/java/cz/cvut/kbss/termit/exception/ValidationExceptionTest.java +++ b/src/test/java/cz/cvut/kbss/termit/exception/ValidationExceptionTest.java @@ -19,10 +19,9 @@ import cz.cvut.kbss.termit.model.UserAccount; import cz.cvut.kbss.termit.validation.ValidationResult; -import org.junit.jupiter.api.Test; - import jakarta.validation.Validation; import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; diff --git a/src/test/java/cz/cvut/kbss/termit/model/DocumentTest.java b/src/test/java/cz/cvut/kbss/termit/model/DocumentTest.java index 1f7fbbda1..e31057ac7 100644 --- a/src/test/java/cz/cvut/kbss/termit/model/DocumentTest.java +++ b/src/test/java/cz/cvut/kbss/termit/model/DocumentTest.java @@ -27,7 +27,11 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.startsWith; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class DocumentTest { diff --git a/src/test/java/cz/cvut/kbss/termit/model/TermTest.java b/src/test/java/cz/cvut/kbss/termit/model/TermTest.java index 4530616bb..796bb463b 100644 --- a/src/test/java/cz/cvut/kbss/termit/model/TermTest.java +++ b/src/test/java/cz/cvut/kbss/termit/model/TermTest.java @@ -30,8 +30,14 @@ import java.util.stream.IntStream; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.emptyCollectionOf; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class TermTest { diff --git a/src/test/java/cz/cvut/kbss/termit/model/UserAccountTest.java b/src/test/java/cz/cvut/kbss/termit/model/UserAccountTest.java index 6c1841038..72cc71ca4 100644 --- a/src/test/java/cz/cvut/kbss/termit/model/UserAccountTest.java +++ b/src/test/java/cz/cvut/kbss/termit/model/UserAccountTest.java @@ -25,7 +25,11 @@ import java.util.Collections; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class UserAccountTest { diff --git a/src/test/java/cz/cvut/kbss/termit/model/acl/RoleAccessControlRecordTest.java b/src/test/java/cz/cvut/kbss/termit/model/acl/RoleAccessControlRecordTest.java index e848791cc..7fb6dce81 100644 --- a/src/test/java/cz/cvut/kbss/termit/model/acl/RoleAccessControlRecordTest.java +++ b/src/test/java/cz/cvut/kbss/termit/model/acl/RoleAccessControlRecordTest.java @@ -26,7 +26,9 @@ import java.net.URI; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class RoleAccessControlRecordTest { diff --git a/src/test/java/cz/cvut/kbss/termit/model/acl/UserAccessControlRecordTest.java b/src/test/java/cz/cvut/kbss/termit/model/acl/UserAccessControlRecordTest.java index 0e472bb48..705be86b8 100644 --- a/src/test/java/cz/cvut/kbss/termit/model/acl/UserAccessControlRecordTest.java +++ b/src/test/java/cz/cvut/kbss/termit/model/acl/UserAccessControlRecordTest.java @@ -23,7 +23,9 @@ import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class UserAccessControlRecordTest { diff --git a/src/test/java/cz/cvut/kbss/termit/model/acl/UserGroupAccessControlRecordTest.java b/src/test/java/cz/cvut/kbss/termit/model/acl/UserGroupAccessControlRecordTest.java index 964570d99..e68955c07 100644 --- a/src/test/java/cz/cvut/kbss/termit/model/acl/UserGroupAccessControlRecordTest.java +++ b/src/test/java/cz/cvut/kbss/termit/model/acl/UserGroupAccessControlRecordTest.java @@ -24,7 +24,9 @@ import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class UserGroupAccessControlRecordTest { diff --git a/src/test/java/cz/cvut/kbss/termit/model/util/EntityToOwlClassMapperTest.java b/src/test/java/cz/cvut/kbss/termit/model/util/EntityToOwlClassMapperTest.java index 8e1ff7984..6e3c11a49 100644 --- a/src/test/java/cz/cvut/kbss/termit/model/util/EntityToOwlClassMapperTest.java +++ b/src/test/java/cz/cvut/kbss/termit/model/util/EntityToOwlClassMapperTest.java @@ -21,7 +21,8 @@ import cz.cvut.kbss.termit.util.Vocabulary; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class EntityToOwlClassMapperTest { @@ -37,4 +38,4 @@ void getOwlClassForEntityThrowsIllegalArgumentForClassNotAnnotatedWithOwlClass() () -> EntityToOwlClassMapper.getOwlClassForEntity(EntityToOwlClassMapper.class)); assertEquals("Class " + EntityToOwlClassMapper.class + " is not an OWL entity.", ex.getMessage()); } -} \ No newline at end of file +} diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/TextAnalysisRecordDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/TextAnalysisRecordDaoTest.java index 37e2af4a2..7eb5a23e8 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/TextAnalysisRecordDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/TextAnalysisRecordDaoTest.java @@ -35,7 +35,9 @@ import java.util.Collections; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class TextAnalysisRecordDaoTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/context/DefaultVocabularyContextMapperTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/context/DefaultVocabularyContextMapperTest.java index 033169c3b..d0f5cdd8a 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/context/DefaultVocabularyContextMapperTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/context/DefaultVocabularyContextMapperTest.java @@ -33,7 +33,11 @@ import java.net.URI; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class DefaultVocabularyContextMapperTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/context/DescriptorFactoryTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/context/DescriptorFactoryTest.java index bb98cb09e..c22fc8a49 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/context/DescriptorFactoryTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/context/DescriptorFactoryTest.java @@ -33,7 +33,9 @@ import java.net.URI; import java.util.Collections; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/context/WorkspaceVocabularyContextMapperTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/context/WorkspaceVocabularyContextMapperTest.java index a32283f9b..e24fdff35 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/context/WorkspaceVocabularyContextMapperTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/context/WorkspaceVocabularyContextMapperTest.java @@ -31,7 +31,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class WorkspaceVocabularyContextMapperTest { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/AssetDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/AssetDaoTest.java index c71d1783b..35023e75d 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/AssetDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/AssetDaoTest.java @@ -43,13 +43,19 @@ import java.net.URI; import java.time.Instant; -import java.util.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class AssetDaoTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/BaseAssetDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/BaseAssetDaoTest.java index 30f4284ad..5ec7ee8a3 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/BaseAssetDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/BaseAssetDaoTest.java @@ -18,7 +18,8 @@ import java.net.URI; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/SnapshotDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/SnapshotDaoTest.java index 45e1f37dd..4ab6c9458 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/SnapshotDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/SnapshotDaoTest.java @@ -33,7 +33,9 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class SnapshotDaoTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoExactMatchTermsTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoExactMatchTermsTest.java index c8a6372bb..122741e10 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoExactMatchTermsTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoExactMatchTermsTest.java @@ -17,17 +17,15 @@ */ package cz.cvut.kbss.termit.persistence.dao; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.not; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - import cz.cvut.kbss.jopa.vocabulary.SKOS; import cz.cvut.kbss.termit.dto.TermInfo; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.model.Term; +import org.eclipse.rdf4j.model.ValueFactory; +import org.eclipse.rdf4j.repository.Repository; +import org.eclipse.rdf4j.repository.RepositoryConnection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.Arrays; @@ -36,11 +34,13 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; -import org.eclipse.rdf4j.model.ValueFactory; -import org.eclipse.rdf4j.repository.Repository; -import org.eclipse.rdf4j.repository.RepositoryConnection; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; public class TermDaoExactMatchTermsTest extends BaseTermDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoRelatedTermsTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoRelatedTermsTest.java index 9b4ac12c2..d920b0df3 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoRelatedTermsTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoRelatedTermsTest.java @@ -25,12 +25,24 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.emptyCollectionOf; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; public class TermDaoRelatedTermsTest extends BaseTermDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoSnapshotsTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoSnapshotsTest.java index 4e755e688..7d62b62b2 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoSnapshotsTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoSnapshotsTest.java @@ -38,14 +38,21 @@ import java.net.URI; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.*; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.IntStream; import static cz.cvut.kbss.termit.environment.util.ContainsSameEntities.containsSameEntities; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) public class TermDaoSnapshotsTest extends BaseTermDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java index 4bb2356b4..da7f09793 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java @@ -21,14 +21,15 @@ import cz.cvut.kbss.jopa.model.JOPAPersistenceProperties; import cz.cvut.kbss.jopa.model.descriptors.Descriptor; import cz.cvut.kbss.jopa.model.descriptors.EntityDescriptor; -import cz.cvut.kbss.jopa.model.query.TypedQuery; import cz.cvut.kbss.termit.dto.assignment.TermOccurrences; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.User; -import cz.cvut.kbss.termit.model.assignment.*; +import cz.cvut.kbss.termit.model.assignment.FileOccurrenceTarget; +import cz.cvut.kbss.termit.model.assignment.TermFileOccurrence; +import cz.cvut.kbss.termit.model.assignment.TermOccurrence; import cz.cvut.kbss.termit.model.resource.Document; import cz.cvut.kbss.termit.model.resource.File; import cz.cvut.kbss.termit.model.selector.TextQuoteSelector; @@ -44,14 +45,25 @@ import org.springframework.test.annotation.DirtiesContext; import java.net.URI; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import java.util.stream.IntStream; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; +import static org.hamcrest.Matchers.emptyCollectionOf; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class TermOccurrenceDaoTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/UserAccountDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/UserAccountDaoTest.java index 600969570..75464dd32 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/UserAccountDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/UserAccountDaoTest.java @@ -32,7 +32,10 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @Tag("dao") diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/acl/AccessControlListDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/acl/AccessControlListDaoTest.java index 66c87522c..a3d016365 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/acl/AccessControlListDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/acl/AccessControlListDaoTest.java @@ -23,7 +23,11 @@ import cz.cvut.kbss.termit.model.User; import cz.cvut.kbss.termit.model.UserRole; import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.model.acl.*; +import cz.cvut.kbss.termit.model.acl.AccessControlList; +import cz.cvut.kbss.termit.model.acl.AccessControlRecord; +import cz.cvut.kbss.termit.model.acl.AccessLevel; +import cz.cvut.kbss.termit.model.acl.RoleAccessControlRecord; +import cz.cvut.kbss.termit.model.acl.UserAccessControlRecord; import cz.cvut.kbss.termit.persistence.context.DescriptorFactory; import cz.cvut.kbss.termit.persistence.context.StaticContexts; import cz.cvut.kbss.termit.persistence.dao.BaseDaoTestRunner; @@ -37,8 +41,14 @@ import static cz.cvut.kbss.termit.environment.util.ContainsSameEntities.containsSameEntities; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class AccessControlListDaoTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeRecordDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeRecordDaoTest.java index e612fac57..5f43ef096 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeRecordDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeRecordDaoTest.java @@ -38,7 +38,13 @@ import java.net.URI; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -47,7 +53,9 @@ import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.empty; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class ChangeRecordDaoTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeTrackingContextResolverTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeTrackingContextResolverTest.java index e8c012b65..1f98a3ebf 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeTrackingContextResolverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeTrackingContextResolverTest.java @@ -35,7 +35,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeTrackingHelperDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeTrackingHelperDaoTest.java index c5d98a471..af757f50d 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeTrackingHelperDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/changetracking/ChangeTrackingHelperDaoTest.java @@ -29,7 +29,10 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; class ChangeTrackingHelperDaoTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/comment/CommentReactionDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/comment/CommentReactionDaoTest.java index d1d2dfcab..315df839a 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/comment/CommentReactionDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/comment/CommentReactionDaoTest.java @@ -34,7 +34,9 @@ import static org.hamcrest.CoreMatchers.hasItem; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; class CommentReactionDaoTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/lucene/LuceneSearchDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/lucene/LuceneSearchDaoTest.java index c4b0c3d38..fd212efdd 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/lucene/LuceneSearchDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/lucene/LuceneSearchDaoTest.java @@ -36,7 +36,12 @@ import static cz.cvut.kbss.termit.persistence.dao.lucene.LuceneSearchDao.LUCENE_WILDCARD; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class LuceneSearchDaoTest { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/SparqlResultToTermInfoMapperTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/SparqlResultToTermInfoMapperTest.java index c523498fb..2012f36b3 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/SparqlResultToTermInfoMapperTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/SparqlResultToTermInfoMapperTest.java @@ -27,7 +27,7 @@ import java.util.Arrays; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; class SparqlResultToTermInfoMapperTest { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/snapshot/CascadingSnapshotCreatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/snapshot/CascadingSnapshotCreatorTest.java index 72fd2d356..f58ee36c8 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/snapshot/CascadingSnapshotCreatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/snapshot/CascadingSnapshotCreatorTest.java @@ -41,7 +41,10 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class CascadingSnapshotCreatorTest extends BaseDaoTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java index cc38b77fc..8198751a2 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java @@ -33,7 +33,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.anyCollection; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ResultCachingValidatorTest { diff --git a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java index b90eb3bec..4075ec3fd 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java @@ -27,7 +27,10 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.accept.ContentNegotiationManager; -import static cz.cvut.kbss.termit.environment.Environment.*; +import static cz.cvut.kbss.termit.environment.Environment.createDefaultMessageConverter; +import static cz.cvut.kbss.termit.environment.Environment.createJsonLdMessageConverter; +import static cz.cvut.kbss.termit.environment.Environment.createResourceMessageConverter; +import static cz.cvut.kbss.termit.environment.Environment.createStringEncodingMessageConverter; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/CommentControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/CommentControllerTest.java index a623543f7..60912418c 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/CommentControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/CommentControllerTest.java @@ -41,8 +41,15 @@ import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ExtendWith(MockitoExtension.class) diff --git a/src/test/java/cz/cvut/kbss/termit/rest/ConfigurationControllerSecurityTest.java b/src/test/java/cz/cvut/kbss/termit/rest/ConfigurationControllerSecurityTest.java index c9ca87796..0f79ceaa9 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/ConfigurationControllerSecurityTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/ConfigurationControllerSecurityTest.java @@ -40,7 +40,9 @@ import static cz.cvut.kbss.termit.util.Constants.REST_MAPPING_PATH; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.when; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java index a9d0e35d9..1b2021a72 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java @@ -32,10 +32,10 @@ import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.ResourceService; import cz.cvut.kbss.termit.service.document.ResourceRetrievalSpecification; -import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; +import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Utils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java index d3e74f5aa..cedfb8e06 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java @@ -41,10 +41,10 @@ import cz.cvut.kbss.termit.service.export.ExportConfig; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.export.ExportType; -import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.Vocabulary; import org.apache.poi.xssf.usermodel.XSSFSheet; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/UserControllerSecurityTest.java b/src/test/java/cz/cvut/kbss/termit/rest/UserControllerSecurityTest.java index 193c10284..47799bfc8 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/UserControllerSecurityTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/UserControllerSecurityTest.java @@ -17,7 +17,6 @@ */ package cz.cvut.kbss.termit.rest; -import cz.cvut.kbss.termit.config.SecurityConfig; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.environment.config.TestRestSecurityConfig; @@ -38,8 +37,13 @@ import static cz.cvut.kbss.termit.service.IdentifierResolver.extractIdentifierFragment; import static cz.cvut.kbss.termit.util.Constants.REST_MAPPING_PATH; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** diff --git a/src/test/java/cz/cvut/kbss/termit/rest/UserControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/UserControllerTest.java index b23e3ccf4..4632fbfb1 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/UserControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/UserControllerTest.java @@ -48,8 +48,14 @@ import static cz.cvut.kbss.termit.service.IdentifierResolver.extractIdentifierFragment; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ExtendWith(MockitoExtension.class) diff --git a/src/test/java/cz/cvut/kbss/termit/rest/UserGroupControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/UserGroupControllerTest.java index b0f7740d1..50179f2c1 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/UserGroupControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/UserGroupControllerTest.java @@ -44,7 +44,9 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ExtendWith(MockitoExtension.class) diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerSecurityTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerSecurityTest.java index 8a6042fad..dfcc17ba2 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerSecurityTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerSecurityTest.java @@ -35,7 +35,9 @@ import static cz.cvut.kbss.termit.environment.Generator.generateVocabulary; import static cz.cvut.kbss.termit.util.Constants.REST_MAPPING_PATH; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index 76b2e0521..0d1c7444d 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -34,14 +34,13 @@ import cz.cvut.kbss.termit.model.acl.AccessLevel; import cz.cvut.kbss.termit.model.acl.UserAccessControlRecord; import cz.cvut.kbss.termit.model.changetracking.AbstractChangeRecord; -import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.rest.handler.ErrorInfo; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.VocabularyService; -import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; +import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -55,7 +54,6 @@ import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.web.servlet.MvcResult; -import org.topbraid.shacl.vocabulary.SH; import java.io.File; import java.math.BigInteger; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/servlet/DiagnosticsContextFilterTest.java b/src/test/java/cz/cvut/kbss/termit/rest/servlet/DiagnosticsContextFilterTest.java index 5f1f4fcd8..062fc281d 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/servlet/DiagnosticsContextFilterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/servlet/DiagnosticsContextFilterTest.java @@ -21,21 +21,23 @@ import cz.cvut.kbss.termit.model.UserAccount; import cz.cvut.kbss.termit.security.model.AuthenticationToken; import cz.cvut.kbss.termit.security.model.TermItUserDetails; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.MDC; -import jakarta.servlet.FilterChain; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.security.Principal; import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class DiagnosticsContextFilterTest { diff --git a/src/test/java/cz/cvut/kbss/termit/rest/util/RestUtilsTest.java b/src/test/java/cz/cvut/kbss/termit/rest/util/RestUtilsTest.java index 619844599..485e6ea29 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/util/RestUtilsTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/util/RestUtilsTest.java @@ -20,6 +20,7 @@ import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Utils; +import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -28,7 +29,6 @@ import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.server.ResponseStatusException; -import jakarta.servlet.http.Cookie; import java.net.URI; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -37,7 +37,10 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.endsWith; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class RestUtilsTest { diff --git a/src/test/java/cz/cvut/kbss/termit/security/AuthenticationFailureTest.java b/src/test/java/cz/cvut/kbss/termit/security/AuthenticationFailureTest.java index 4fdd09c65..f712c4974 100644 --- a/src/test/java/cz/cvut/kbss/termit/security/AuthenticationFailureTest.java +++ b/src/test/java/cz/cvut/kbss/termit/security/AuthenticationFailureTest.java @@ -32,7 +32,9 @@ import static cz.cvut.kbss.termit.security.AuthenticationSuccessTest.request; import static cz.cvut.kbss.termit.security.AuthenticationSuccessTest.response; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; @Tag("security") class AuthenticationFailureTest { diff --git a/src/test/java/cz/cvut/kbss/termit/security/AuthenticationSuccessTest.java b/src/test/java/cz/cvut/kbss/termit/security/AuthenticationSuccessTest.java index b59e19307..46a15dac7 100644 --- a/src/test/java/cz/cvut/kbss/termit/security/AuthenticationSuccessTest.java +++ b/src/test/java/cz/cvut/kbss/termit/security/AuthenticationSuccessTest.java @@ -32,7 +32,10 @@ import org.springframework.mock.web.MockHttpSession; import org.springframework.security.core.Authentication; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("security") class AuthenticationSuccessTest { diff --git a/src/test/java/cz/cvut/kbss/termit/security/JwtUtilsTest.java b/src/test/java/cz/cvut/kbss/termit/security/JwtUtilsTest.java index 5ee713ed0..aef1a3506 100644 --- a/src/test/java/cz/cvut/kbss/termit/security/JwtUtilsTest.java +++ b/src/test/java/cz/cvut/kbss/termit/security/JwtUtilsTest.java @@ -43,14 +43,22 @@ import java.nio.charset.StandardCharsets; import java.security.Key; -import java.util.*; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import static cz.cvut.kbss.termit.security.model.TermItUserDetails.DEFAULT_AUTHORITY; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.greaterThan; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("security") @ExtendWith({SpringExtension.class, MockitoExtension.class}) diff --git a/src/test/java/cz/cvut/kbss/termit/security/OntologicalAuthenticationProviderTest.java b/src/test/java/cz/cvut/kbss/termit/security/OntologicalAuthenticationProviderTest.java index 859cdbbdc..82968ef1f 100644 --- a/src/test/java/cz/cvut/kbss/termit/security/OntologicalAuthenticationProviderTest.java +++ b/src/test/java/cz/cvut/kbss/termit/security/OntologicalAuthenticationProviderTest.java @@ -49,8 +49,15 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.instanceOf; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @Tag("security") @ExtendWith(MockitoExtension.class) diff --git a/src/test/java/cz/cvut/kbss/termit/security/model/TermItUserDetailsTest.java b/src/test/java/cz/cvut/kbss/termit/security/model/TermItUserDetailsTest.java index 0dc8e67e8..55011a84e 100644 --- a/src/test/java/cz/cvut/kbss/termit/security/model/TermItUserDetailsTest.java +++ b/src/test/java/cz/cvut/kbss/termit/security/model/TermItUserDetailsTest.java @@ -27,7 +27,9 @@ import java.util.Collections; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; class TermItUserDetailsTest { @@ -78,4 +80,4 @@ void getUserReturnsCopyOfUser() { assertEquals(user, result); assertNotSame(user, result); } -} \ No newline at end of file +} diff --git a/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java b/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java index d918e4574..7b38d4e55 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java @@ -26,7 +26,11 @@ import java.net.URI; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/ResourceServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/ResourceServiceTest.java index 068a86516..6119b0f90 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/ResourceServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/ResourceServiceTest.java @@ -33,9 +33,9 @@ import cz.cvut.kbss.termit.service.document.DocumentManager; import cz.cvut.kbss.termit.service.document.ResourceRetrievalSpecification; import cz.cvut.kbss.termit.service.document.TextAnalysisService; -import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.ResourceRepositoryService; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.Utils; import org.jsoup.Jsoup; diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/SearchServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/SearchServiceTest.java index 1a57954d3..a4e7fcccb 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/SearchServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/SearchServiceTest.java @@ -43,8 +43,14 @@ import java.util.List; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyCollection; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class SearchServiceTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java index 22a7898d7..2a4781da5 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java @@ -38,12 +38,12 @@ import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.export.ExportType; import cz.cvut.kbss.termit.service.export.VocabularyExporters; -import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.service.language.LanguageService; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; +import cz.cvut.kbss.termit.util.TypeAwareByteArrayResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.Utils; import org.junit.jupiter.api.Test; diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/readonly/ReadOnlyTermServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/readonly/ReadOnlyTermServiceTest.java index 2dd2eda9c..5dffc660a 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/readonly/ReadOnlyTermServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/readonly/ReadOnlyTermServiceTest.java @@ -53,7 +53,10 @@ import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/readonly/ReadOnlyVocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/readonly/ReadOnlyVocabularyServiceTest.java index 90b2b7fc2..9bc8b11b4 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/readonly/ReadOnlyVocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/readonly/ReadOnlyVocabularyServiceTest.java @@ -40,7 +40,10 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/src/test/java/cz/cvut/kbss/termit/service/changetracking/MetamodelBasedChangeCalculatorTest.java b/src/test/java/cz/cvut/kbss/termit/service/changetracking/MetamodelBasedChangeCalculatorTest.java index 74f834ce4..eba418e2c 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/changetracking/MetamodelBasedChangeCalculatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/changetracking/MetamodelBasedChangeCalculatorTest.java @@ -40,8 +40,12 @@ import java.util.stream.IntStream; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.emptyCollectionOf; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class MetamodelBasedChangeCalculatorTest extends BaseServiceTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManagerTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManagerTest.java index b17976107..b2f2e1ae6 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManagerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/DefaultDocumentManagerTest.java @@ -53,7 +53,12 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.startsWith; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; @ContextConfiguration(initializers = {PropertyMockingApplicationContextInitializer.class}) class DefaultDocumentManagerTest extends BaseServiceTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java index 4f56a27ba..560d6ddd0 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java @@ -65,9 +65,22 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; diff --git a/src/test/java/cz/cvut/kbss/termit/service/export/ExcelTermExporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/export/ExcelTermExporterTest.java index 80927ceda..c6baf03a5 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/export/ExcelTermExporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/export/ExcelTermExporterTest.java @@ -31,7 +31,13 @@ import org.junit.jupiter.api.Test; import java.net.URI; -import java.util.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; diff --git a/src/test/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporterTest.java index d09176d90..1288bf35f 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/export/ExcelVocabularyExporterTest.java @@ -47,7 +47,12 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.oneOf; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/src/test/java/cz/cvut/kbss/termit/service/export/SKOSVocabularyExporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/export/SKOSVocabularyExporterTest.java index 46ebd73b9..0a969b836 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/export/SKOSVocabularyExporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/export/SKOSVocabularyExporterTest.java @@ -36,7 +36,11 @@ import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.impl.LinkedHashModel; import org.eclipse.rdf4j.model.impl.SimpleValueFactory; -import org.eclipse.rdf4j.model.vocabulary.*; +import org.eclipse.rdf4j.model.vocabulary.DCTERMS; +import org.eclipse.rdf4j.model.vocabulary.OWL; +import org.eclipse.rdf4j.model.vocabulary.RDF; +import org.eclipse.rdf4j.model.vocabulary.RDFS; +import org.eclipse.rdf4j.model.vocabulary.SKOS; import org.eclipse.rdf4j.repository.Repository; import org.eclipse.rdf4j.repository.RepositoryConnection; import org.eclipse.rdf4j.rio.RDFFormat; @@ -50,7 +54,11 @@ import org.springframework.http.MediaType; import java.io.IOException; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import static org.hamcrest.CoreMatchers.hasItem; diff --git a/src/test/java/cz/cvut/kbss/termit/service/init/AdminAccountGeneratorTest.java b/src/test/java/cz/cvut/kbss/termit/service/init/AdminAccountGeneratorTest.java index 43e8a362a..834a6c2d0 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/init/AdminAccountGeneratorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/init/AdminAccountGeneratorTest.java @@ -41,11 +41,16 @@ import java.util.List; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class AdminAccountGeneratorTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/init/VocabularyAccessControlListGeneratorTest.java b/src/test/java/cz/cvut/kbss/termit/service/init/VocabularyAccessControlListGeneratorTest.java index 4b8071de0..ae328686e 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/init/VocabularyAccessControlListGeneratorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/init/VocabularyAccessControlListGeneratorTest.java @@ -24,7 +24,6 @@ import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.acl.AccessControlList; import cz.cvut.kbss.termit.service.business.AccessControlListService; -import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -36,9 +35,13 @@ import java.util.List; import java.util.stream.Collectors; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class VocabularyAccessControlListGeneratorTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/language/UfoTermTypesServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/language/UfoTermTypesServiceTest.java index a1619304e..b95a3e232 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/language/UfoTermTypesServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/language/UfoTermTypesServiceTest.java @@ -31,7 +31,9 @@ import java.util.List; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) diff --git a/src/test/java/cz/cvut/kbss/termit/service/mail/AssetLinkTest.java b/src/test/java/cz/cvut/kbss/termit/service/mail/AssetLinkTest.java index 68d805acc..1b56c9697 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/mail/AssetLinkTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/mail/AssetLinkTest.java @@ -30,7 +30,9 @@ import java.util.Map; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.Matchers.startsWith; class AssetLinkTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/notification/CommentChangeNotifierTest.java b/src/test/java/cz/cvut/kbss/termit/service/notification/CommentChangeNotifierTest.java index bbde3c782..0f84df634 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/notification/CommentChangeNotifierTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/notification/CommentChangeNotifierTest.java @@ -44,7 +44,12 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.*; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -52,8 +57,12 @@ import static cz.cvut.kbss.termit.service.notification.CommentChangeNotifier.COMMENT_CHANGES_TEMPLATE; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItems; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/src/test/java/cz/cvut/kbss/termit/service/notification/NotificationServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/notification/NotificationServiceTest.java index 83c48e7a0..738037100 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/notification/NotificationServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/notification/NotificationServiceTest.java @@ -39,7 +39,9 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.lessThan; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class NotificationServiceTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryServiceImpl.java b/src/test/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryServiceImpl.java index 37f95d768..905710cd1 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryServiceImpl.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryServiceImpl.java @@ -21,9 +21,8 @@ import cz.cvut.kbss.termit.persistence.dao.BaseAssetDao; import cz.cvut.kbss.termit.persistence.dao.TermDao; import cz.cvut.kbss.termit.service.security.SecurityUtils; -import org.springframework.beans.factory.annotation.Autowired; - import jakarta.validation.Validator; +import org.springframework.beans.factory.annotation.Autowired; public class BaseAssetRepositoryServiceImpl extends BaseAssetRepositoryService { diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryServiceTest.java index 770c04c5e..bee8a2772 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/BaseAssetRepositoryServiceTest.java @@ -31,6 +31,7 @@ import cz.cvut.kbss.termit.persistence.dao.TermDao; import cz.cvut.kbss.termit.service.BaseServiceTestRunner; import cz.cvut.kbss.termit.service.security.SecurityUtils; +import jakarta.validation.Validator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -41,7 +42,6 @@ import org.springframework.test.annotation.DirtiesContext; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; -import jakarta.validation.Validator; import java.net.URI; import java.time.Instant; import java.util.List; @@ -49,7 +49,9 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) class BaseAssetRepositoryServiceTest extends BaseServiceTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryServiceImpl.java b/src/test/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryServiceImpl.java index 97ae6a249..9c2dd4d5b 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryServiceImpl.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryServiceImpl.java @@ -20,9 +20,8 @@ import cz.cvut.kbss.termit.model.UserAccount; import cz.cvut.kbss.termit.persistence.dao.GenericDao; import cz.cvut.kbss.termit.persistence.dao.UserAccountDao; -import org.springframework.beans.factory.annotation.Autowired; - import jakarta.validation.Validator; +import org.springframework.beans.factory.annotation.Autowired; public class BaseRepositoryServiceImpl extends BaseRepositoryService { diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryServiceTest.java index 2c9b4a07d..15b4daa32 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryServiceTest.java @@ -23,6 +23,7 @@ import cz.cvut.kbss.termit.model.UserAccount; import cz.cvut.kbss.termit.persistence.dao.UserAccountDao; import cz.cvut.kbss.termit.service.BaseServiceTestRunner; +import jakarta.validation.Validator; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.InOrder; @@ -33,15 +34,24 @@ import org.springframework.context.annotation.Bean; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; -import jakarta.validation.Validator; import java.net.URI; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @Tag("service") class BaseRepositoryServiceTest extends BaseServiceTestRunner { diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListServiceTest.java index a4532d325..a21a0944e 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListServiceTest.java @@ -24,7 +24,12 @@ import cz.cvut.kbss.termit.model.User; import cz.cvut.kbss.termit.model.UserAccount; import cz.cvut.kbss.termit.model.UserRole; -import cz.cvut.kbss.termit.model.acl.*; +import cz.cvut.kbss.termit.model.acl.AccessControlList; +import cz.cvut.kbss.termit.model.acl.AccessControlRecord; +import cz.cvut.kbss.termit.model.acl.AccessLevel; +import cz.cvut.kbss.termit.model.acl.RoleAccessControlRecord; +import cz.cvut.kbss.termit.model.acl.UserAccessControlRecord; +import cz.cvut.kbss.termit.model.acl.UserGroupAccessControlRecord; import cz.cvut.kbss.termit.persistence.dao.acl.AccessControlListDao; import cz.cvut.kbss.termit.service.security.SecurityUtils; import cz.cvut.kbss.termit.util.Configuration; @@ -47,8 +52,13 @@ import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.Matchers.not; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class RepositoryAccessControlListServiceTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryServiceTest.java index 4cb5ce854..df9fae710 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryServiceTest.java @@ -29,6 +29,8 @@ import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Vocabulary; +import jakarta.validation.Validation; +import jakarta.validation.Validator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,16 +40,17 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import jakarta.validation.Validation; -import jakarta.validation.Validator; - import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.collection.IsEmptyCollection.empty; import static org.hamcrest.core.AnyOf.anyOf; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ResourceRepositoryServiceTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/UserRepositoryServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/UserRepositoryServiceTest.java index 1041c23d4..aef217484 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/UserRepositoryServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/UserRepositoryServiceTest.java @@ -25,6 +25,8 @@ import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Vocabulary; +import jakarta.validation.Validation; +import jakarta.validation.Validator; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -35,15 +37,20 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import jakarta.validation.Validation; -import jakarta.validation.Validator; import java.net.URI; import java.util.Optional; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class UserRepositoryServiceTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/security/RuntimeBasedLoginTrackerTest.java b/src/test/java/cz/cvut/kbss/termit/service/security/RuntimeBasedLoginTrackerTest.java index 5a84b4a51..1d9d0ee5f 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/security/RuntimeBasedLoginTrackerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/security/RuntimeBasedLoginTrackerTest.java @@ -35,8 +35,13 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = {RuntimeBasedLoginTrackerTest.Config.class}) diff --git a/src/test/java/cz/cvut/kbss/termit/service/security/TermItUserDetailsServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/security/TermItUserDetailsServiceTest.java index 0525978b1..91d204fcb 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/security/TermItUserDetailsServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/security/TermItUserDetailsServiceTest.java @@ -26,7 +26,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UsernameNotFoundException; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; class TermItUserDetailsServiceTest extends BaseServiceTestRunner { @@ -53,4 +55,4 @@ void loadUserByUsernameThrowsUsernameNotFoundForUnknownUsername() { assertThrows(UsernameNotFoundException.class, () -> sut.loadUserByUsername(username)); assertEquals("User with username " + username + " not found.", ex.getMessage()); } -} \ No newline at end of file +} diff --git a/src/test/java/cz/cvut/kbss/termit/service/security/authorization/ResourceAuthorizationServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/security/authorization/ResourceAuthorizationServiceTest.java index 5905b7470..27eb6d2c1 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/security/authorization/ResourceAuthorizationServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/security/authorization/ResourceAuthorizationServiceTest.java @@ -27,7 +27,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/src/test/java/cz/cvut/kbss/termit/service/security/authorization/SnapshotAuthorizationServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/security/authorization/SnapshotAuthorizationServiceTest.java index 6b0829f3c..c654482f6 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/security/authorization/SnapshotAuthorizationServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/security/authorization/SnapshotAuthorizationServiceTest.java @@ -41,7 +41,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class SnapshotAuthorizationServiceTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/security/authorization/VocabularyAuthorizationServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/security/authorization/VocabularyAuthorizationServiceTest.java index f7c0204c5..db2362516 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/security/authorization/VocabularyAuthorizationServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/security/authorization/VocabularyAuthorizationServiceTest.java @@ -39,8 +39,14 @@ import java.util.stream.Stream; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class VocabularyAuthorizationServiceTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/security/authorization/acl/AccessControlListBasedAuthorizationServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/security/authorization/acl/AccessControlListBasedAuthorizationServiceTest.java index dc765c5e5..6ed873ad8 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/security/authorization/acl/AccessControlListBasedAuthorizationServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/security/authorization/acl/AccessControlListBasedAuthorizationServiceTest.java @@ -22,7 +22,11 @@ import cz.cvut.kbss.termit.model.UserGroup; import cz.cvut.kbss.termit.model.UserRole; import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.model.acl.*; +import cz.cvut.kbss.termit.model.acl.AccessControlList; +import cz.cvut.kbss.termit.model.acl.AccessLevel; +import cz.cvut.kbss.termit.model.acl.RoleAccessControlRecord; +import cz.cvut.kbss.termit.model.acl.UserAccessControlRecord; +import cz.cvut.kbss.termit.model.acl.UserGroupAccessControlRecord; import cz.cvut.kbss.termit.service.business.AccessControlListService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -38,8 +42,12 @@ import java.util.Optional; import java.util.stream.Stream; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class AccessControlListBasedAuthorizationServiceTest { diff --git a/src/test/java/cz/cvut/kbss/termit/service/term/AssertedInferredValueDifferentiatorTest.java b/src/test/java/cz/cvut/kbss/termit/service/term/AssertedInferredValueDifferentiatorTest.java index af0d3f9e4..b44dcad6f 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/term/AssertedInferredValueDifferentiatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/term/AssertedInferredValueDifferentiatorTest.java @@ -28,7 +28,11 @@ import java.util.stream.IntStream; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.emptyCollectionOf; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertEquals; class AssertedInferredValueDifferentiatorTest { @@ -138,4 +142,4 @@ void differentiateExactMatchDoesNothingWhenOriginalInferredAreNotPresent() { assertEquals(asserted, target.getExactMatchTerms()); assertThat(target.getInverseExactMatchTerms(), anyOf(emptyCollectionOf(TermInfo.class), nullValue())); } -} \ No newline at end of file +} diff --git a/src/test/java/cz/cvut/kbss/termit/util/AuthenticatingServletRequestWrapperTest.java b/src/test/java/cz/cvut/kbss/termit/util/AuthenticatingServletRequestWrapperTest.java index 1fdfa680e..fc393dc06 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/AuthenticatingServletRequestWrapperTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/AuthenticatingServletRequestWrapperTest.java @@ -33,7 +33,9 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.hamcrest.Matchers.startsWith; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class AuthenticatingServletRequestWrapperTest { diff --git a/src/test/java/cz/cvut/kbss/termit/util/CsvUtilsTest.java b/src/test/java/cz/cvut/kbss/termit/util/CsvUtilsTest.java index f6dee52dc..b3702b8d6 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/CsvUtilsTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/CsvUtilsTest.java @@ -19,7 +19,7 @@ import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; class CsvUtilsTest { @@ -47,4 +47,4 @@ void sanitizeStringDuplicatesDoubleQuotesInString() { final String result = CsvUtils.sanitizeString(input); assertEquals("\"This string needs \"\"escaping\"\"\"", result); } -} \ No newline at end of file +} diff --git a/src/test/java/cz/cvut/kbss/termit/util/UtilsTest.java b/src/test/java/cz/cvut/kbss/termit/util/UtilsTest.java index 6473c6a09..64d939e6c 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/UtilsTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/UtilsTest.java @@ -37,7 +37,12 @@ import org.locationtech.jts.util.Assert; import org.mockito.Mockito; -import java.util.*; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; diff --git a/src/test/java/cz/cvut/kbss/termit/validation/MultilingualStringPrimaryNotBlankValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/validation/MultilingualStringPrimaryNotBlankValidatorTest.java index eff40f102..f5cca5cd1 100644 --- a/src/test/java/cz/cvut/kbss/termit/validation/MultilingualStringPrimaryNotBlankValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/validation/MultilingualStringPrimaryNotBlankValidatorTest.java @@ -19,6 +19,7 @@ import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.termit.util.Configuration; +import jakarta.validation.ConstraintValidatorContext; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Answers; @@ -26,8 +27,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import jakarta.validation.ConstraintValidatorContext; - import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; diff --git a/src/test/java/cz/cvut/kbss/termit/workspace/EditableVocabulariesTest.java b/src/test/java/cz/cvut/kbss/termit/workspace/EditableVocabulariesTest.java index 5ae9b734a..43243539b 100644 --- a/src/test/java/cz/cvut/kbss/termit/workspace/EditableVocabulariesTest.java +++ b/src/test/java/cz/cvut/kbss/termit/workspace/EditableVocabulariesTest.java @@ -31,7 +31,9 @@ import java.net.URI; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class EditableVocabulariesTest { From 0dde50283ab9310f6c8c0ccdf8b7b183f4ab600a Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 9 Sep 2024 08:18:19 +0200 Subject: [PATCH 074/150] [Bug#293] Remove square brackets when generating identifier. --- .../cz/cvut/kbss/termit/service/IdentifierResolver.java | 2 +- .../cvut/kbss/termit/service/IdentifierResolverTest.java | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java b/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java index 1adb75184..b9230cd0d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java @@ -98,7 +98,7 @@ public static String normalize(String value) { return value.toLowerCase() .trim() .replaceAll("[\\s/\\\\]", Character.toString(REPLACEMENT_CHARACTER)) - .replaceAll("[(?&$#§),]", ""); + .replaceAll("[(?&$#§),\\[\\]]", ""); } /** diff --git a/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java b/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java index 7b38d4e55..b43d4af70 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java @@ -319,4 +319,12 @@ void generateIdentifierRemovesSectionSign() { final URI result = sut.generateIdentifier(namespace, label); assertEquals(URI.create(namespace + "/je-povinným-subjektem-podle-2-zákona-106-1999-sb."), result); } + + @Test + void generateIdentifierRemovesSquareBrackets() { + final String namespace = Vocabulary.s_c_slovnik; + final String label = "Délka dostřiku [m]"; + final URI result = sut.generateIdentifier(namespace, label); + assertEquals(URI.create(namespace + "/délka-dostřiku-m"), result); + } } From cc82de9418ddf9737538283449c2322fdbc8b73e Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 9 Sep 2024 08:30:20 +0200 Subject: [PATCH 075/150] [Ref] Add messageId to TermItException so that all exceptions can have message id for the frontend localization. --- .../exception/AuthorizationException.java | 6 ++++- .../exception/InvalidIdentifierException.java | 8 +++++++ ...InvalidPasswordChangeRequestException.java | 8 +------ .../exception/InvalidTermStateException.java | 9 +------- .../termit/exception/TermItException.java | 23 +++++++++++++++++++ .../kbss/termit/rest/handler/ErrorInfo.java | 3 +-- .../rest/handler/RestExceptionHandler.java | 15 ++++++++---- 7 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java diff --git a/src/main/java/cz/cvut/kbss/termit/exception/AuthorizationException.java b/src/main/java/cz/cvut/kbss/termit/exception/AuthorizationException.java index 39e39fd59..8ae13819d 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/AuthorizationException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/AuthorizationException.java @@ -18,11 +18,15 @@ package cz.cvut.kbss.termit.exception; /** - * Indicates that a the user attempted to access a resources/function for which they have insufficient authority. + * Indicates that the user attempted to access a resources/function for which they have insufficient authority. */ public class AuthorizationException extends TermItException { public AuthorizationException(String message) { super(message); } + + public AuthorizationException(String message, String messageId) { + super(message, messageId); + } } diff --git a/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java b/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java new file mode 100644 index 000000000..830a0c01f --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java @@ -0,0 +1,8 @@ +package cz.cvut.kbss.termit.exception; + +public class InvalidIdentifierException extends TermItException { + + public InvalidIdentifierException(String message) { + super(message); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/exception/InvalidPasswordChangeRequestException.java b/src/main/java/cz/cvut/kbss/termit/exception/InvalidPasswordChangeRequestException.java index 41862435f..fec78aa19 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/InvalidPasswordChangeRequestException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/InvalidPasswordChangeRequestException.java @@ -2,13 +2,7 @@ public class InvalidPasswordChangeRequestException extends AuthorizationException { - private final String messageId; public InvalidPasswordChangeRequestException(String message, String messageId) { - super(message); - this.messageId = messageId; - } - - public String getMessageId() { - return messageId; + super(message, messageId); } } diff --git a/src/main/java/cz/cvut/kbss/termit/exception/InvalidTermStateException.java b/src/main/java/cz/cvut/kbss/termit/exception/InvalidTermStateException.java index 987afcbad..e4eb0447b 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/InvalidTermStateException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/InvalidTermStateException.java @@ -24,14 +24,7 @@ */ public class InvalidTermStateException extends TermItException { - private final String messageId; - public InvalidTermStateException(String message, String messageId) { - super(message); - this.messageId = messageId; - } - - public String getMessageId() { - return messageId; + super(message, messageId); } } diff --git a/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java b/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java index f433481c2..0477c6b7f 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java @@ -24,6 +24,11 @@ */ public class TermItException extends RuntimeException { + /** + * Identifier of localized frontend message. + */ + private String messageId; + protected TermItException() { } @@ -31,11 +36,29 @@ public TermItException(String message) { super(message); } + public TermItException(String message, String messageId) { + super(message); + this.messageId = messageId; + } + public TermItException(String message, Throwable cause) { super(message, cause); } + public TermItException(String message, String messageId, Throwable cause) { + super(message, cause); + this.messageId = messageId; + } + public TermItException(Throwable cause) { super(cause); } + + public String getMessageId() { + return messageId; + } + + public void setMessageId(String messageId) { + this.messageId = messageId; + } } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/ErrorInfo.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/ErrorInfo.java index ee83e09df..32ba557b1 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/ErrorInfo.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/ErrorInfo.java @@ -97,8 +97,7 @@ public static ErrorInfo createWithMessage(String message, String requestUri) { * @return New {@code ErrorInfo} instance */ public static ErrorInfo createWithMessageAndMessageId(String message, String messageId, String requestUri) { - final ErrorInfo errorInfo = new ErrorInfo(requestUri); - errorInfo.setMessage(message); + ErrorInfo errorInfo = createWithMessage(message, requestUri); errorInfo.setMessageId(messageId); return errorInfo; } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index 03d50a199..22ef1e099 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -57,6 +57,7 @@ * The general pattern should be that unless an exception can be handled in a more appropriate place it bubbles up to a * REST controller which originally received the request. There, it is caught by this handler, logged and a reasonable * error message is returned to the user. + * * @implSpec Should reflect {@link cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler} */ @RestControllerAdvice @@ -87,6 +88,10 @@ private static ErrorInfo errorInfo(HttpServletRequest request, Throwable e) { return ErrorInfo.createWithMessage(e.getMessage(), request.getRequestURI()); } + private static ErrorInfo errorInfo(HttpServletRequest request, TermItException e) { + return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), request.getRequestURI()); + } + @ExceptionHandler(PersistenceException.class) public ResponseEntity persistenceException(HttpServletRequest request, PersistenceException e) { logException(e, request); @@ -170,8 +175,7 @@ public ResponseEntity annotationGenerationException(HttpServletReques } @ExceptionHandler(TermItException.class) - public ResponseEntity termItException(HttpServletRequest request, - TermItException e) { + public ResponseEntity termItException(HttpServletRequest request, TermItException e) { logException(e, request); return new ResponseEntity<>(errorInfo(request, e), HttpStatus.INTERNAL_SERVER_ERROR); } @@ -260,8 +264,11 @@ public ResponseEntity invalidTermStateException(HttpServletRequest re } @ExceptionHandler - public ResponseEntity invalidPasswordChangeRequestException(HttpServletRequest request, InvalidPasswordChangeRequestException e) { + public ResponseEntity invalidPasswordChangeRequestException(HttpServletRequest request, + InvalidPasswordChangeRequestException e) { logException(e, request); - return new ResponseEntity<>(ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), request.getRequestURI()), HttpStatus.CONFLICT); + return new ResponseEntity<>( + ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), request.getRequestURI()), + HttpStatus.CONFLICT); } } From e8ed3ac098b78340734eb0411630acef5acf20a1 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 9 Sep 2024 08:46:25 +0200 Subject: [PATCH 076/150] [Bug #293] Add more general handling of identifier generation errors. When an invalid unescaped character appears in a generated identifier, throw an application exception that is handled by REST handler and can be displayed to the user. --- .../exception/InvalidIdentifierException.java | 4 ++-- .../rest/handler/RestExceptionHandler.java | 8 ++++++++ .../termit/service/IdentifierResolver.java | 20 +++++++++++++------ .../handler/WebSocketExceptionHandler.java | 19 ++++++++++++++---- .../service/IdentifierResolverTest.java | 8 ++++++++ 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java b/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java index 830a0c01f..2ef873889 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java @@ -2,7 +2,7 @@ public class InvalidIdentifierException extends TermItException { - public InvalidIdentifierException(String message) { - super(message); + public InvalidIdentifierException(String message, String messageId) { + super(message, messageId); } } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index 22ef1e099..d83ab552d 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -23,6 +23,7 @@ import cz.cvut.kbss.termit.exception.AnnotationGenerationException; import cz.cvut.kbss.termit.exception.AssetRemovalException; import cz.cvut.kbss.termit.exception.AuthorizationException; +import cz.cvut.kbss.termit.exception.InvalidIdentifierException; import cz.cvut.kbss.termit.exception.InvalidLanguageConstantException; import cz.cvut.kbss.termit.exception.InvalidParameterException; import cz.cvut.kbss.termit.exception.InvalidPasswordChangeRequestException; @@ -271,4 +272,11 @@ public ResponseEntity invalidPasswordChangeRequestException(HttpServl ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), request.getRequestURI()), HttpStatus.CONFLICT); } + + @ExceptionHandler + public ResponseEntity invalidIdentifierException(HttpServletRequest request, + InvalidIdentifierException e) { + logException(e, request); + return new ResponseEntity<>(errorInfo(request, e), HttpStatus.CONFLICT); + } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java b/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java index b9230cd0d..232f5b7ef 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java @@ -17,6 +17,7 @@ */ package cz.cvut.kbss.termit.service; +import cz.cvut.kbss.termit.exception.InvalidIdentifierException; import cz.cvut.kbss.termit.exception.TermItException; import org.springframework.stereotype.Service; @@ -85,7 +86,7 @@ public class IdentifierResolver { *

  • Transforming the value to lower case
  • *
  • Trimming the string
  • *
  • Replacing white spaces and slashes with dashes
  • - *
  • Removing parentheses
  • + *
  • Removing invalid characters such as parentheses, square brackets, dollar sign, exclamation mark, etc.
  • * *

    * Based on https://gist.github.com/rponte/893494 @@ -98,7 +99,7 @@ public static String normalize(String value) { return value.toLowerCase() .trim() .replaceAll("[\\s/\\\\]", Character.toString(REPLACEMENT_CHARACTER)) - .replaceAll("[(?&$#§),\\[\\]]", ""); + .replaceAll("[(?&$#§),\\[\\]@]", ""); } /** @@ -138,7 +139,13 @@ public URI generateIdentifier(String namespace, String... components) { if (!namespace.endsWith("/") && !namespace.endsWith("#")) { namespace += "/"; } - return URI.create(namespace + normalize(comps)); + try { + return URI.create(namespace + normalize(comps)); + } catch (IllegalArgumentException e) { + throw new InvalidIdentifierException( + "Generated identifier " + namespace + normalize(comps) + " is not a valid URI.", + "error.identifier.invalidCharacters"); + } } private static boolean isUri(String value) { @@ -170,7 +177,8 @@ public URI generateDerivedIdentifier(URI baseUri, String namespaceSeparator, Str /** * Generates a synthetic identifier using the specified base URL. *

    - * This particular implementation uses the current system time in millis to generate the locally unique part of the identifier. + * This particular implementation uses the current system time in millis to generate the locally unique part of the + * identifier. * * @param base URL base * @return Synthetic identifier containing the specified base @@ -185,8 +193,8 @@ public static URI generateSyntheticIdentifier(String base) { * This method assumes that the fragment is a normalized string uniquely identifying a resource in the specified * namespace. *

    - * Basically, the returned identifier should be the same as would be returned for non-normalized fragments by {@link - * #generateIdentifier(String, String...)}. + * Basically, the returned identifier should be the same as would be returned for non-normalized fragments by + * {@link #generateIdentifier(String, String...)}. * * @param namespace Identifier namespace * @param fragment Normalized string unique in the specified namespace diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index e94b99450..83895d6cc 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -6,6 +6,7 @@ import cz.cvut.kbss.termit.exception.AnnotationGenerationException; import cz.cvut.kbss.termit.exception.AssetRemovalException; import cz.cvut.kbss.termit.exception.AuthorizationException; +import cz.cvut.kbss.termit.exception.InvalidIdentifierException; import cz.cvut.kbss.termit.exception.InvalidLanguageConstantException; import cz.cvut.kbss.termit.exception.InvalidParameterException; import cz.cvut.kbss.termit.exception.InvalidPasswordChangeRequestException; @@ -77,6 +78,10 @@ private static ErrorInfo errorInfo(Message message, Throwable e) { return ErrorInfo.createWithMessage(e.getMessage(), destination(message)); } + private static ErrorInfo errorInfo(Message message, TermItException e) { + return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), destination(message)); + } + @MessageExceptionHandler public void messageDeliveryException(Message message, MessageDeliveryException e) { // messages without destination will be logged only on trace @@ -190,7 +195,7 @@ public ErrorInfo unsupportedAssetOperationException(Message message, Unsuppor @MessageExceptionHandler(VocabularyImportException.class) public ErrorInfo vocabularyImportException(Message message, VocabularyImportException e) { logException(e, message); - return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), destination(message)); + return errorInfo(message, e); } @MessageExceptionHandler @@ -220,7 +225,7 @@ public ErrorInfo maxUploadSizeExceededException(Message message, MaxUploadSiz @MessageExceptionHandler public ErrorInfo snapshotNotEditableException(Message message, SnapshotNotEditableException e) { logException(e, message); - return ErrorInfo.createWithMessage(e.getMessage(), destination(message)); + return errorInfo(message, e); } @MessageExceptionHandler @@ -238,13 +243,19 @@ public ErrorInfo invalidLanguageConstantException(Message message, InvalidLan @MessageExceptionHandler public ErrorInfo invalidTermStateException(Message message, InvalidTermStateException e) { logException(e, message); - return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), destination(message)); + return errorInfo(message, e); } @MessageExceptionHandler public ErrorInfo invalidPasswordChangeRequestException(Message message, InvalidPasswordChangeRequestException e) { logException(e, message); - return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), destination(message)); + return errorInfo(message, e); + } + + @MessageExceptionHandler + public ErrorInfo invalidIdentifierException(Message message, InvalidIdentifierException e) { + logException(e, message); + return errorInfo(message, e); } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java b/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java index b43d4af70..0ba3136a4 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java @@ -19,6 +19,7 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.exception.InvalidIdentifierException; import cz.cvut.kbss.termit.util.Vocabulary; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -327,4 +328,11 @@ void generateIdentifierRemovesSquareBrackets() { final URI result = sut.generateIdentifier(namespace, label); assertEquals(URI.create(namespace + "/délka-dostřiku-m"), result); } + + @Test + void generateIdentifierThrowsInvalidIdentifierExceptionWhenComponentsContainsUnforeseenInvalidCharacters() { + final String namespace = Vocabulary.s_c_slovnik; + final String label = "label with emoji \u3000"; // Ideographic space + assertThrows(InvalidIdentifierException.class, () -> sut.generateIdentifier(namespace, label)); + } } From ca71866546f74b78c76c9aeffe92da7dd0d84e34 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 15 Aug 2024 13:39:32 +0200 Subject: [PATCH 077/150] Revert "[HotFix] Allow disabling automatic text analysis of all vocabulary terms on term edit." This reverts commit 8c9d08683a9923ee6fe3ebcd60ff49e5c20764b2. --- .../termit/service/business/TermService.java | 16 ++++++---------- .../cz/cvut/kbss/termit/util/Configuration.java | 10 ---------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index 983f00eb1..b43edbb7e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -374,10 +374,8 @@ public void persistRoot(Term term, Vocabulary owner) { Objects.requireNonNull(owner); languageService.getInitialTermState().ifPresent(is -> term.setState(is.getUri())); repositoryService.addRootTermToVocabulary(term, owner); - if (!config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) { - analyzeTermDefinition(term, owner.getUri()); - vocabularyService.runTextAnalysisOnAllTerms(owner); - } + analyzeTermDefinition(term, owner.getUri()); + vocabularyService.runTextAnalysisOnAllTerms(owner); } /** @@ -392,10 +390,8 @@ public void persistChild(Term child, Term parent) { Objects.requireNonNull(parent); languageService.getInitialTermState().ifPresent(is -> child.setState(is.getUri())); repositoryService.addChildTerm(child, parent); - if (!config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) { - analyzeTermDefinition(child, parent.getVocabulary()); - vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(parent.getVocabulary())); - } + analyzeTermDefinition(child, parent.getVocabulary()); + vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(parent.getVocabulary())); } /** @@ -412,10 +408,10 @@ public Term update(Term term) { checkForInvalidTerminalStateAssignment(original, term.getState()); // Ensure the change is merged into the repo before analyzing other terms final Term result = repositoryService.update(term); - if (!Objects.equals(original.getDefinition(), term.getDefinition()) && !config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) { + if (!Objects.equals(original.getDefinition(), term.getDefinition())) { analyzeTermDefinition(term, original.getVocabulary()); } - if (!Objects.equals(original.getLabel(), term.getLabel()) && !config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) { + if (!Objects.equals(original.getLabel(), term.getLabel())) { vocabularyService.runTextAnalysisOnAllTerms(getVocabularyReference(original.getVocabulary())); } return result; diff --git a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java index 1b7bcaf11..5f43aa236 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java @@ -600,8 +600,6 @@ public static class TextAnalysis { @Min(8) private int textQuoteSelectorContextLength = 32; - private boolean disableVocabularyAnalysisOnTermEdit = false; - public String getUrl() { return url; } @@ -625,14 +623,6 @@ public int getTextQuoteSelectorContextLength() { public void setTextQuoteSelectorContextLength(int textQuoteSelectorContextLength) { this.textQuoteSelectorContextLength = textQuoteSelectorContextLength; } - - public boolean isDisableVocabularyAnalysisOnTermEdit() { - return disableVocabularyAnalysisOnTermEdit; - } - - public void setDisableVocabularyAnalysisOnTermEdit(boolean disableVocabularyAnalysisOnTermEdit) { - this.disableVocabularyAnalysisOnTermEdit = disableVocabularyAnalysisOnTermEdit; - } } @Validated From 4969e2ab69d5b494ac1f50f32f87ef0790375e35 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Fri, 16 Aug 2024 16:17:25 +0200 Subject: [PATCH 078/150] Debounce aspect --- .../cz/cvut/kbss/termit/config/AppConfig.java | 14 ++ .../persistence/dao/TermOccurrenceDao.java | 15 +- .../termit/persistence/dao/VocabularyDao.java | 12 +- .../dao/util/ScheduledContextRemover.java | 65 ----- .../termit/rest/VocabularyController.java | 2 + .../termit/service/business/TermService.java | 13 +- .../service/business/VocabularyService.java | 26 +- .../business/async/AsyncTermService.java | 69 ------ .../service/document/AnnotationGenerator.java | 2 + .../document/html/SelectorGenerator.java | 7 +- .../VocabularyRepositoryService.java | 6 +- .../cz/cvut/kbss/termit/util/Constants.java | 21 ++ .../kbss/termit/util/debounce/Debounce.java | 46 ++++ .../termit/util/debounce/DebounceAspect.java | 234 ++++++++++++++++++ src/main/resources/spring-aop.xml | 21 ++ 15 files changed, 382 insertions(+), 171 deletions(-) delete mode 100644 src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java delete mode 100644 src/main/java/cz/cvut/kbss/termit/service/business/async/AsyncTermService.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/debounce/Debounce.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/debounce/DebounceAspect.java create mode 100644 src/main/resources/spring-aop.xml diff --git a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java index 9a6ec39d7..60dc97aaa 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java @@ -19,14 +19,17 @@ import cz.cvut.kbss.termit.util.AsyncExceptionHandler; import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.context.annotation.EnableMBeanExport; +import org.springframework.context.annotation.ImportResource; import org.springframework.context.annotation.aspectj.EnableSpringConfigured; import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; @Configuration @EnableMBeanExport @@ -35,10 +38,21 @@ @EnableAsync @EnableScheduling @EnableRetry +@ImportResource("classpath*:spring-aop.xml") public class AppConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new AsyncExceptionHandler(); } + + @Bean + public ThreadPoolTaskScheduler threadPoolTaskScheduler() { + ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler(); + threadPoolTaskScheduler.setPoolSize(1); + threadPoolTaskScheduler.setThreadNamePrefix("TermItScheduler-"); + threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true); + threadPoolTaskScheduler.setRemoveOnCancelPolicy(true); + return threadPoolTaskScheduler; + } } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java index 373991a1e..6aa08c722 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java @@ -29,7 +29,6 @@ import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.assignment.TermOccurrence; -import cz.cvut.kbss.termit.persistence.dao.util.ScheduledContextRemover; import cz.cvut.kbss.termit.persistence.dao.util.SparqlResultToTermOccurrenceMapper; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Vocabulary; @@ -80,12 +79,9 @@ public class TermOccurrenceDao extends BaseDao { private final Configuration.Persistence config; - private final ScheduledContextRemover contextRemover; - - public TermOccurrenceDao(EntityManager em, Configuration config, ScheduledContextRemover contextRemover) { + public TermOccurrenceDao(EntityManager em, Configuration config) { super(TermOccurrence.class, em); this.config = config.getPersistence(); - this.contextRemover = contextRemover; } /** @@ -258,12 +254,9 @@ public void removeAll(Asset target) { Objects.requireNonNull(target); final URI sourceContext = TermOccurrence.resolveContext(target.getUri()); - final URI targetContext = URI.create(sourceContext + "-for-removal-" + System.currentTimeMillis()); - em.createNativeQuery("MOVE GRAPH ?g TO ?targetContext") - .setParameter("g", sourceContext) - .setParameter("targetContext", targetContext) - .executeUpdate(); - contextRemover.scheduleForRemoval(targetContext); + em.createNativeQuery("DROP GRAPH ?context") + .setParameter("context", sourceContext) + .executeUpdate(); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index 1f04fe548..90fd87bf7 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -147,14 +147,14 @@ public Vocabulary getReference(URI id) { * @param entity Base vocabulary, whose imports should be retrieved * @return Collection of (transitively) imported vocabularies */ - public Collection getTransitivelyImportedVocabularies(Vocabulary entity) { - Objects.requireNonNull(entity); + public Collection getTransitivelyImportedVocabularies(URI vocabulary) { + Objects.requireNonNull(vocabulary); try { return em.createNativeQuery("SELECT DISTINCT ?imported WHERE {" + "?x ?imports+ ?imported ." + "}", URI.class) .setParameter("imports", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_p_importuje_slovnik)) - .setParameter("x", entity.getUri()).getResultList(); + .setParameter("x", vocabulary).getResultList(); } catch (RuntimeException e) { throw new PersistenceException(e); } @@ -357,10 +357,10 @@ public void refreshLastModified(RefreshLastModifiedEvent event) { } @Transactional - public List validateContents(Vocabulary voc) { + public List validateContents(URI vocabulary) { final VocabularyContentValidator validator = context.getBean(VocabularyContentValidator.class); - final Collection importClosure = getTransitivelyImportedVocabularies(voc); - importClosure.add(voc.getUri()); + final Collection importClosure = getTransitivelyImportedVocabularies(vocabulary); + importClosure.add(vocabulary); return validator.validate(importClosure); } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java deleted file mode 100644 index 326fe0ab0..000000000 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java +++ /dev/null @@ -1,65 +0,0 @@ -package cz.cvut.kbss.termit.persistence.dao.util; - -import cz.cvut.kbss.jopa.model.EntityManager; -import cz.cvut.kbss.termit.util.Utils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.NonNull; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.net.URI; -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -/** - * Drops registered repository contexts at scheduled moments. - *

    - * This allows to move time-consuming removal of repository contexts containing a lot of data to times of low system - * activity. - */ -@Component -public class ScheduledContextRemover { - - private static final Logger LOG = LoggerFactory.getLogger(ScheduledContextRemover.class); - - private final EntityManager em; - - private final Set contextsToRemove = new HashSet<>(); - - public ScheduledContextRemover(EntityManager em) { - this.em = em; - } - - /** - * Schedules the specified context identifier for removal at the next execution of the context cleanup. - * - * @param contextUri Identifier of the context to remove - * @see #runContextRemoval() - */ - public synchronized void scheduleForRemoval(@NonNull URI contextUri) { - LOG.debug("Scheduling context {} for removal.", Utils.uriToString(contextUri)); - contextsToRemove.add(Objects.requireNonNull(contextUri)); - } - - /** - * Runs the removal of the registered repository contexts. - *

    - * This method is scheduled and should not be invoked manually. - * - * @see #scheduleForRemoval(URI) - */ - @Transactional - @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) - public synchronized void runContextRemoval() { - LOG.trace("Running scheduled repository context removal."); - contextsToRemove.forEach(g -> { - LOG.trace("Dropping repository context {}.", Utils.uriToString(g)); - em.createNativeQuery("DROP GRAPH ?g").setParameter("g", g).executeUpdate(); - }); - contextsToRemove.clear(); - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 881e6b71b..5a3a6b6aa 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -27,6 +27,7 @@ import cz.cvut.kbss.termit.model.acl.AccessControlRecord; import cz.cvut.kbss.termit.model.acl.AccessLevel; import cz.cvut.kbss.termit.model.changetracking.AbstractChangeRecord; +import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.rest.doc.ApiDocConstants; import cz.cvut.kbss.termit.rest.util.RestUtils; import cz.cvut.kbss.termit.security.SecurityConstants; @@ -68,6 +69,7 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.concurrent.ExecutionException; /** * Vocabulary management REST API. diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index b43edbb7e..844256c10 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -374,7 +374,6 @@ public void persistRoot(Term term, Vocabulary owner) { Objects.requireNonNull(owner); languageService.getInitialTermState().ifPresent(is -> term.setState(is.getUri())); repositoryService.addRootTermToVocabulary(term, owner); - analyzeTermDefinition(term, owner.getUri()); vocabularyService.runTextAnalysisOnAllTerms(owner); } @@ -390,8 +389,7 @@ public void persistChild(Term child, Term parent) { Objects.requireNonNull(parent); languageService.getInitialTermState().ifPresent(is -> child.setState(is.getUri())); repositoryService.addChildTerm(child, parent); - analyzeTermDefinition(child, parent.getVocabulary()); - vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(parent.getVocabulary())); + vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(child.getVocabulary())); } /** @@ -408,11 +406,14 @@ public Term update(Term term) { checkForInvalidTerminalStateAssignment(original, term.getState()); // Ensure the change is merged into the repo before analyzing other terms final Term result = repositoryService.update(term); - if (!Objects.equals(original.getDefinition(), term.getDefinition())) { - analyzeTermDefinition(term, original.getVocabulary()); - } + + // if the label changed, run analysis on all terms in the vocabulary if (!Objects.equals(original.getLabel(), term.getLabel())) { vocabularyService.runTextAnalysisOnAllTerms(getVocabularyReference(original.getVocabulary())); + // if all terms have not been analyzed, check if the definition has changed, + // and if so, perform an analysis for the term definition + } else if (!Objects.equals(original.getDefinition(), term.getDefinition())) { + analyzeTermDefinition(term, original.getVocabulary()); } return result; } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 69a2dfc22..51a5bff6d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -34,7 +34,6 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper; import cz.cvut.kbss.termit.persistence.snapshot.SnapshotCreator; -import cz.cvut.kbss.termit.service.business.async.AsyncTermService; import cz.cvut.kbss.termit.service.changetracking.ChangeRecordProvider; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.repository.ChangeRecordService; @@ -42,9 +41,11 @@ import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.TypeAwareClasspathResource; import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.TypeAwareResource; +import cz.cvut.kbss.termit.util.debounce.Debounce; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -61,6 +62,7 @@ import java.io.File; import java.net.URI; +import java.net.URISyntaxException; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; @@ -70,6 +72,8 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; import static cz.cvut.kbss.termit.util.Constants.VOCABULARY_REMOVAL_IGNORED_RELATIONS; @@ -90,7 +94,7 @@ public class VocabularyService private final ChangeRecordService changeRecordService; - private final AsyncTermService termService; + private final TermService termService; private final VocabularyContextMapper contextMapper; @@ -104,7 +108,7 @@ public class VocabularyService public VocabularyService(VocabularyRepositoryService repositoryService, ChangeRecordService changeRecordService, - @Lazy AsyncTermService termService, + @Lazy TermService termService, VocabularyContextMapper contextMapper, AccessControlListService aclService, VocabularyAuthorizationService authorizationService, @@ -290,8 +294,11 @@ public List getChangesOfContent(Vocabulary vocabulary) { * @param vocabulary Vocabulary to be analyzed */ @Transactional + @Debounce(value = "{vocabulary.getUri()}", + group = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY_TERMS_ALL_DEFINITIONS) @PreAuthorize("@vocabularyAuthorizationService.canModify(#vocabulary)") public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { + vocabulary = findRequired(vocabulary.getUri()); LOG.debug("Analyzing definitions of all terms in vocabulary {} and vocabularies it imports.", vocabulary); SnapshotProvider.verifySnapshotNotModified(vocabulary); final List allTerms = termService.findAll(vocabulary); @@ -299,12 +306,14 @@ public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { importedVocabulary -> allTerms.addAll(termService.findAll(getReference(importedVocabulary)))); final Map termsToContexts = new HashMap<>(allTerms.size()); allTerms.forEach(t -> termsToContexts.put(t, contextMapper.getVocabularyContext(t.getVocabulary()))); - termService.asyncAnalyzeTermDefinitions(termsToContexts); + termsToContexts.forEach(termService::analyzeTermDefinition); } /** * Runs text analysis on definitions of all terms in all vocabularies. */ + @Debounce(group = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY, + clearGroup = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY) @Transactional public void runTextAnalysisOnAllVocabularies() { LOG.debug("Analyzing definitions of all terms in all vocabularies."); @@ -312,7 +321,7 @@ public void runTextAnalysisOnAllVocabularies() { repositoryService.findAll().forEach(v -> { List terms = termService.findAll(new Vocabulary(v.getUri())); terms.forEach(t -> termsToContexts.put(t, contextMapper.getVocabularyContext(t.getVocabulary()))); - termService.asyncAnalyzeTermDefinitions(termsToContexts); + termsToContexts.forEach(termService::analyzeTermDefinition); }); } @@ -337,10 +346,11 @@ public void remove(Vocabulary asset) { /** * Validates a vocabulary: - it checks glossary rules, - it checks OntoUml constraints. * - * @param validate Vocabulary to validate + * @param vocabulary Vocabulary to validate */ - public List validateContents(Vocabulary validate) { - return repositoryService.validateContents(validate); + @Debounce("{vocabulary}") + public Future> validateContents(URI vocabulary) { + return new FutureTask<>(() -> repositoryService.validateContents(vocabulary)); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/async/AsyncTermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/async/AsyncTermService.java deleted file mode 100644 index fc807b733..000000000 --- a/src/main/java/cz/cvut/kbss/termit/service/business/async/AsyncTermService.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * TermIt - * Copyright (C) 2023 Czech Technical University in Prague - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package cz.cvut.kbss.termit.service.business.async; - -import cz.cvut.kbss.termit.dto.listing.TermDto; -import cz.cvut.kbss.termit.model.AbstractTerm; -import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.service.business.TermService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -import java.net.URI; -import java.util.List; -import java.util.Map; - -/** - * Provides asynchronous processing of term-related tasks. - */ -@Service -public class AsyncTermService { - - private static final Logger LOG = LoggerFactory.getLogger(AsyncTermService.class); - - private final TermService termService; - - public AsyncTermService(TermService termService) { - this.termService = termService; - } - - /** - * Gets a list of all terms in the specified vocabulary. - * - * @param vocabulary Vocabulary whose terms to retrieve. A reference is sufficient - * @return List of vocabulary term DTOs - */ - public List findAll(Vocabulary vocabulary) { - return termService.findAll(vocabulary); - } - - /** - * Asynchronously runs text analysis on the definitions of all the specified terms. - *

    - * The analysis calls are executed in a sequence, but this method itself is executed asynchronously. - * - * @param termsWithContexts Map of terms to vocabulary context identifiers they belong to - */ - @Async - public void asyncAnalyzeTermDefinitions(Map termsWithContexts) { - LOG.trace("Asynchronously analyzing definitions of {} terms.", termsWithContexts.size()); - termsWithContexts.forEach(termService::analyzeTermDefinition); - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index 4333be04a..65c7d7c3b 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -21,6 +21,7 @@ import cz.cvut.kbss.termit.model.AbstractTerm; import cz.cvut.kbss.termit.model.assignment.TermOccurrence; import cz.cvut.kbss.termit.model.resource.File; +import cz.cvut.kbss.termit.util.debounce.Debounce; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -95,6 +96,7 @@ private void saveAnnotatedContent(File file, InputStream input) { * @param annotatedTerm Term whose definition was annotated */ @Transactional + @Debounce(value = "{annotatedTerm.getUri()}") public void generateAnnotations(InputStream content, AbstractTerm annotatedTerm) { // We assume the content (text analysis output) is HTML-compatible final TermOccurrenceResolver occurrenceResolver = resolvers.htmlTermOccurrenceResolver(); diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/SelectorGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/SelectorGenerator.java index b13049475..a55c7a022 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/SelectorGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/SelectorGenerator.java @@ -57,10 +57,11 @@ default String extractExactText(Element[] elements) { default StringBuilder extractNodeText(Iterable nodes) { final StringBuilder sb = new StringBuilder(); for (Node node : nodes) { - if (!(node instanceof TextNode) && !(node instanceof Element)) { - continue; + if (node instanceof TextNode textNode) { + sb.append(textNode.getWholeText()); + } else if (node instanceof Element elementNode) { + sb.append(elementNode.wholeText()); } - sb.append(node instanceof TextNode ? ((TextNode) node).getWholeText() : ((Element) node).wholeText()); } return sb; } diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index a9730c702..acca3b1bb 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -205,7 +205,7 @@ public Vocabulary update(Vocabulary instance) { } public Collection getTransitivelyImportedVocabularies(Vocabulary entity) { - return vocabularyDao.getTransitivelyImportedVocabularies(entity); + return vocabularyDao.getTransitivelyImportedVocabularies(entity.getUri()); } public Set getRelatedVocabularies(Vocabulary entity) { @@ -319,8 +319,8 @@ private void ensureNoTermRelationsExists(Vocabulary vocabulary) throws AssetRemo } } - public List validateContents(Vocabulary instance) { - return vocabularyDao.validateContents(instance); + public List validateContents(URI vocabulary) { + return vocabularyDao.validateContents(vocabulary); } public Integer getTermCount(Vocabulary vocabulary) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index fb0959d8f..5d7ead6a9 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -207,6 +207,10 @@ public static final class MediaType { public static final String EXCEL = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; public static final String TURTLE = "text/turtle"; public static final String RDF_XML = "application/rdf+xml"; + + private MediaType() { + throw new AssertionError(); + } } /** @@ -244,6 +248,23 @@ private QueryParams() { } } + public static final class DebouncingGroups { + + /** + * Text analysis of all terms in specific vocabulary + */ + public static final String TEXT_ANALYSIS_VOCABULARY_TERMS_ALL_DEFINITIONS = "TEXT_ANALYSIS_VOCABULARY_TERMS_ALL_DEFINITIONS"; + + /** + * Text analysis of all vocabularies + */ + public static final String TEXT_ANALYSIS_VOCABULARY = "TEXT_ANALYSIS_VOCABULARY"; + + private DebouncingGroups() { + throw new AssertionError(); + } + } + /** * the maximum amount of data to buffer when sending messages to a WebSocket session */ diff --git a/src/main/java/cz/cvut/kbss/termit/util/debounce/Debounce.java b/src/main/java/cz/cvut/kbss/termit/util/debounce/Debounce.java new file mode 100644 index 000000000..8c8d9f1ba --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/debounce/Debounce.java @@ -0,0 +1,46 @@ +package cz.cvut.kbss.termit.util.debounce; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that calls to this method will be debounced. + *

    + * Every annotated method should be tested for debouncing. + *

    + * Method can't use any parameters that are part of persistent context, they need to be re-requested. + *

    + * Available only for methods returning {@code void}, {@link Void} and {@link java.util.concurrent.Future}. + * Example implementation: + * + * @Debounced("{paramObj}") + * public Future + * + * @see Debouncing and Throttling + * @implNote The annotation is being processed by {@link DebounceAspect#debounceMethodCall} + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Debounce { + + /** + * @return The Spring-EL expression returning a List of Objects which will be used to construct the unique identifier + * for this debounced instance. In the expression, you have available method parameters. + */ + @NotNull String value() default ""; + + /** + * @return The group identifier to which this debounce belongs to + */ + @NotNull String group() default ""; + + /** + * @return A prefix of a group that will be cleared on debouncing. + * All pending tasks with a prefix of this value will be canceled in favor of this task. + */ + @NotNull String clearGroup() default ""; +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/debounce/DebounceAspect.java b/src/main/java/cz/cvut/kbss/termit/util/debounce/DebounceAspect.java new file mode 100644 index 000000000..374182be4 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/debounce/DebounceAspect.java @@ -0,0 +1,234 @@ +package cz.cvut.kbss.termit.util.debounce; + +import cz.cvut.kbss.termit.exception.TermItException; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Scope; +import org.springframework.core.annotation.Order; +import org.springframework.expression.AccessException; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.PropertyAccessor; +import org.springframework.expression.TypedValue; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.DataBindingMethodResolver; +import org.springframework.expression.spel.support.SimpleEvaluationContext; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_SINGLETON; + +/** + * @implNote The aspect is configured in {@code spring-aop.xml}, this uses Spring AOP instead of AspectJ. + */ +@Order +@Scope(SCOPE_SINGLETON) +@Component("debounceAspect") +public class DebounceAspect { + + private static final Logger LOG = LoggerFactory.getLogger(DebounceAspect.class); + + private static final NavigableMap> store = new ConcurrentSkipListMap<>(); + + private static final Set debouncedThreads = ConcurrentHashMap.newKeySet(); + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final ExpressionParser parser = new SpelExpressionParser(); + + private final TaskScheduler taskScheduler; + + @Autowired + public DebounceAspect(TaskScheduler taskScheduler) { + this.taskScheduler = taskScheduler; + } + + private static void resolveParameters(Map map, MethodSignature signature, JoinPoint joinPoint) { + final String[] paramNames = signature.getParameterNames(); + final Object[] params = joinPoint.getArgs(); + + for (int i = 0; i < params.length; i++) { + map.putIfAbsent(paramNames[i], params[i]); + } + } + + private static EvaluationContext makeContext(JoinPoint joinPoint, Map parameters) { + return SimpleEvaluationContext.forPropertyAccessors(new MapPropertyAccessor<>(joinPoint.getTarget() + .getClass(), parameters)) + .withMethodResolvers(DataBindingMethodResolver.forInstanceMethodInvocation()) + .withRootObject(joinPoint.getTarget()).build(); + } + + private static @NotNull FutureTask getFutureTask(@NotNull ProceedingJoinPoint joinPoint, String key) { + final Supplier securityContext = SecurityContextHolder.getDeferredContext(); + final Class returnType = ((MethodSignature) joinPoint.getSignature()).getReturnType(); + final boolean resolveFuture = returnType.isAssignableFrom(Future.class); + + return new FutureTask<>(() -> { + final Long threadId = Thread.currentThread().getId(); + debouncedThreads.add(threadId); + LOG.trace("Running debounced task {}", key); + SecurityContextHolder.setContext(securityContext.get()); + try { + if (resolveFuture) { + Future future = (Future) joinPoint.proceed(); + if (future instanceof Runnable runnable) { + runnable.run(); + } + return future.get(); + } else { + return joinPoint.proceed(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw e; + } catch (Throwable e) { + throw new TermItException(e); + } finally { + SecurityContextHolder.clearContext(); + LOG.trace("Debounced task run finished {}", key); + debouncedThreads.remove(threadId); + } + }); + } + + /** + * @return future or null + * @throws TermItException when the target method throws + * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or {@link Future} + * @implNote Around advice configured in {@code spring-aop.xml} + */ + @SuppressWarnings("unused") + public @Nullable Object debounceMethodCall(@NotNull ProceedingJoinPoint joinPoint, + @NotNull Debounce debounceAnnotation) + throws Throwable { + + if (debouncedThreads.contains(Thread.currentThread().getId())) { + return joinPoint.proceed(); + } + + final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + + final String key = makeKey(joinPoint, debounceAnnotation); + LOG.trace("Debouncing task with key {}", key); + + if (!debounceAnnotation.clearGroup().isBlank()) { + store.tailMap(debounceAnnotation.clearGroup()).clear(); + } + + FutureTask task = getFutureTask(joinPoint, key); + + Object result = voidOrFuture(signature, task); + + if (store.containsKey(key)) { + boolean canceled = store.get(key).cancel(false); + if (canceled) { + LOG.trace("Old task canceled {}", key); + } + } + + store.put(key, taskScheduler.schedule(task, Instant.now().plus(TIMEOUT))); + + return result; + } + + private String makeKey(JoinPoint joinPoint, Debounce debounceAnnotation) throws IllegalCallerException { + final String identifier = constructIdentifier(joinPoint, debounceAnnotation.value()); + final String groupIdentifier = debounceAnnotation.group(); + + if (identifier == null) { + throw new IllegalCallerException("Identifier in Debounce annotation resolved to null"); + } + + return groupIdentifier + "-" + joinPoint.getSignature() + "-" + identifier; + } + + private @Nullable Object voidOrFuture(@NotNull MethodSignature signature, FutureTask task) + throws IllegalCallerException { + Class returnType = signature.getReturnType(); + if (returnType.isAssignableFrom(Future.class)) { + return task; + } + if (Void.TYPE.equals(returnType) || Void.class.equals(returnType)) { + return null; + } + throw new IllegalCallerException("Invalid return type for " + signature + " annotated with @Debounce, only Future or void allowed!"); + } + + private @Nullable String constructIdentifier(JoinPoint joinPoint, String expression) { + if (expression == null || expression.isBlank()) { + return ""; + } + + final Map parameters = new HashMap<>(); + resolveParameters(parameters, (MethodSignature) joinPoint.getSignature(), joinPoint); + assert !parameters.isEmpty(); + + final EvaluationContext context = makeContext(joinPoint, parameters); + + final List identifier = parser.parseExpression(expression).getValue(context, List.class); + assert identifier != null; + + return identifier.stream().map(Object::toString).collect(Collectors.joining("-")); + } + + /** + * Resolves properties to map values + * + * @param rootClass a class this accessor should accept as target's class + * @param map the map with values + */ + private record MapPropertyAccessor(Class rootClass, Map map) implements PropertyAccessor { + + @Override + public Class[] getSpecificTargetClasses() { + return new Class[]{rootClass, map.getClass()}; + } + + @Override + public boolean canRead(@NotNull EvaluationContext context, Object target, @NotNull String name) + throws AccessException { + return map.containsKey(name); + } + + @Override + public @NotNull TypedValue read(@NotNull EvaluationContext context, Object target, @NotNull String name) + throws AccessException { + return new TypedValue(map.get(name)); + } + + @Override + public boolean canWrite(@NotNull EvaluationContext context, Object target, @NotNull String name) + throws AccessException { + return false; + } + + @Override + public void write(@NotNull EvaluationContext context, Object target, @NotNull String name, Object newValue) + throws AccessException { + throw new AccessException("Unsupported operation"); + } + } +} diff --git a/src/main/resources/spring-aop.xml b/src/main/resources/spring-aop.xml new file mode 100644 index 000000000..66217ad60 --- /dev/null +++ b/src/main/resources/spring-aop.xml @@ -0,0 +1,21 @@ + + + + AOP related definitions + + + + + + + + + + From f397a02f2973927d329dc08edd2e1063a31aaa69 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 20 Aug 2024 16:02:20 +0200 Subject: [PATCH 079/150] Throttle aspect with spring aop --- doc/throttle-debounce.png | Bin 0 -> 5438 bytes .../cz/cvut/kbss/termit/config/AppConfig.java | 2 +- .../termit/service/business/TermService.java | 10 +- .../service/business/VocabularyService.java | 14 +- .../service/document/AnnotationGenerator.java | 4 +- .../kbss/termit/util/debounce/Debounce.java | 46 --- .../termit/util/debounce/DebounceAspect.java | 234 ------------ .../kbss/termit/util/throttle/Throttle.java | 61 ++++ .../termit/util/throttle/ThrottleAspect.java | 340 ++++++++++++++++++ .../termit/util/throttle/ThrottledFuture.java | 131 +++++++ src/main/resources/spring-aop.xml | 11 +- .../termit/environment/util/MockedFuture.java | 57 +++ .../dao/TermOccurrenceDaoTest.java | 9 +- .../persistence/dao/VocabularyDaoTest.java | 2 +- .../dao/util/ScheduledContextRemoverTest.java | 73 ++-- .../termit/rest/VocabularyControllerTest.java | 2 + .../service/business/TermServiceTest.java | 28 +- .../repository/VocabularyServiceTest.java | 16 +- .../util/throttle/MockedFutureTask.java | 38 ++ .../util/throttle/MockedMethodSignature.java | 87 +++++ .../termit/util/throttle/MockedThrottle.java | 52 +++ .../util/throttle/ThrottleAspectTest.java | 203 +++++++++++ 22 files changed, 1062 insertions(+), 358 deletions(-) create mode 100644 doc/throttle-debounce.png delete mode 100644 src/main/java/cz/cvut/kbss/termit/util/debounce/Debounce.java delete mode 100644 src/main/java/cz/cvut/kbss/termit/util/debounce/DebounceAspect.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java create mode 100644 src/test/java/cz/cvut/kbss/termit/environment/util/MockedFuture.java create mode 100644 src/test/java/cz/cvut/kbss/termit/util/throttle/MockedFutureTask.java create mode 100644 src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java create mode 100644 src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java create mode 100644 src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java diff --git a/doc/throttle-debounce.png b/doc/throttle-debounce.png new file mode 100644 index 0000000000000000000000000000000000000000..75fcb8910c061fc609312065bcd06f3edb83d019 GIT binary patch literal 5438 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 S$?q&j1yxxA0?wCv_5NSu^SA>5 literal 0 HcmV?d00001 diff --git a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java index 60dc97aaa..4619a211c 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java @@ -49,7 +49,7 @@ public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { @Bean public ThreadPoolTaskScheduler threadPoolTaskScheduler() { ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler(); - threadPoolTaskScheduler.setPoolSize(1); + threadPoolTaskScheduler.setPoolSize(1); // TODO config value threadPoolTaskScheduler.setThreadNamePrefix("TermItScheduler-"); threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true); threadPoolTaskScheduler.setRemoveOnCancelPolicy(true); diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index 844256c10..5b674825c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -389,7 +389,7 @@ public void persistChild(Term child, Term parent) { Objects.requireNonNull(parent); languageService.getInitialTermState().ifPresent(is -> child.setState(is.getUri())); repositoryService.addChildTerm(child, parent); - vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(child.getVocabulary())); + vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(parent.getVocabulary())); } /** @@ -408,12 +408,12 @@ public Term update(Term term) { final Term result = repositoryService.update(term); // if the label changed, run analysis on all terms in the vocabulary - if (!Objects.equals(original.getLabel(), term.getLabel())) { - vocabularyService.runTextAnalysisOnAllTerms(getVocabularyReference(original.getVocabulary())); + if (!Objects.equals(original.getLabel(), result.getLabel())) { + vocabularyService.runTextAnalysisOnAllTerms(getVocabularyReference(result.getVocabulary())); // if all terms have not been analyzed, check if the definition has changed, // and if so, perform an analysis for the term definition - } else if (!Objects.equals(original.getDefinition(), term.getDefinition())) { - analyzeTermDefinition(term, original.getVocabulary()); + } else if (!Objects.equals(original.getDefinition(), result.getDefinition())) { + analyzeTermDefinition(result, result.getVocabulary()); } return result; } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 51a5bff6d..d33390eb3 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -45,7 +45,8 @@ import cz.cvut.kbss.termit.util.TypeAwareClasspathResource; import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.TypeAwareResource; -import cz.cvut.kbss.termit.util.debounce.Debounce; +import cz.cvut.kbss.termit.util.throttle.Throttle; +import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,7 +74,6 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.Future; -import java.util.concurrent.FutureTask; import static cz.cvut.kbss.termit.util.Constants.VOCABULARY_REMOVAL_IGNORED_RELATIONS; @@ -294,11 +294,11 @@ public List getChangesOfContent(Vocabulary vocabulary) { * @param vocabulary Vocabulary to be analyzed */ @Transactional - @Debounce(value = "{vocabulary.getUri()}", + @Throttle(value = "{vocabulary.getUri()}", group = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY_TERMS_ALL_DEFINITIONS) @PreAuthorize("@vocabularyAuthorizationService.canModify(#vocabulary)") public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { - vocabulary = findRequired(vocabulary.getUri()); + vocabulary = findRequired(vocabulary.getUri()); // required when throttling LOG.debug("Analyzing definitions of all terms in vocabulary {} and vocabularies it imports.", vocabulary); SnapshotProvider.verifySnapshotNotModified(vocabulary); final List allTerms = termService.findAll(vocabulary); @@ -312,7 +312,7 @@ public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { /** * Runs text analysis on definitions of all terms in all vocabularies. */ - @Debounce(group = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY, + @Throttle(group = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY, clearGroup = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY) @Transactional public void runTextAnalysisOnAllVocabularies() { @@ -348,9 +348,9 @@ public void remove(Vocabulary asset) { * * @param vocabulary Vocabulary to validate */ - @Debounce("{vocabulary}") + @Throttle("{vocabulary}") public Future> validateContents(URI vocabulary) { - return new FutureTask<>(() -> repositoryService.validateContents(vocabulary)); + return ThrottledFuture.of(() -> repositoryService.validateContents(vocabulary)); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index 65c7d7c3b..e196a14d4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -21,7 +21,7 @@ import cz.cvut.kbss.termit.model.AbstractTerm; import cz.cvut.kbss.termit.model.assignment.TermOccurrence; import cz.cvut.kbss.termit.model.resource.File; -import cz.cvut.kbss.termit.util.debounce.Debounce; +import cz.cvut.kbss.termit.util.throttle.Throttle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -96,7 +96,7 @@ private void saveAnnotatedContent(File file, InputStream input) { * @param annotatedTerm Term whose definition was annotated */ @Transactional - @Debounce(value = "{annotatedTerm.getUri()}") + @Throttle(value = "{annotatedTerm.getUri()}") public void generateAnnotations(InputStream content, AbstractTerm annotatedTerm) { // We assume the content (text analysis output) is HTML-compatible final TermOccurrenceResolver occurrenceResolver = resolvers.htmlTermOccurrenceResolver(); diff --git a/src/main/java/cz/cvut/kbss/termit/util/debounce/Debounce.java b/src/main/java/cz/cvut/kbss/termit/util/debounce/Debounce.java deleted file mode 100644 index 8c8d9f1ba..000000000 --- a/src/main/java/cz/cvut/kbss/termit/util/debounce/Debounce.java +++ /dev/null @@ -1,46 +0,0 @@ -package cz.cvut.kbss.termit.util.debounce; - -import org.jetbrains.annotations.NotNull; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Indicates that calls to this method will be debounced. - *

    - * Every annotated method should be tested for debouncing. - *

    - * Method can't use any parameters that are part of persistent context, they need to be re-requested. - *

    - * Available only for methods returning {@code void}, {@link Void} and {@link java.util.concurrent.Future}. - * Example implementation: - * - * @Debounced("{paramObj}") - * public Future - * - * @see Debouncing and Throttling - * @implNote The annotation is being processed by {@link DebounceAspect#debounceMethodCall} - */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface Debounce { - - /** - * @return The Spring-EL expression returning a List of Objects which will be used to construct the unique identifier - * for this debounced instance. In the expression, you have available method parameters. - */ - @NotNull String value() default ""; - - /** - * @return The group identifier to which this debounce belongs to - */ - @NotNull String group() default ""; - - /** - * @return A prefix of a group that will be cleared on debouncing. - * All pending tasks with a prefix of this value will be canceled in favor of this task. - */ - @NotNull String clearGroup() default ""; -} diff --git a/src/main/java/cz/cvut/kbss/termit/util/debounce/DebounceAspect.java b/src/main/java/cz/cvut/kbss/termit/util/debounce/DebounceAspect.java deleted file mode 100644 index 374182be4..000000000 --- a/src/main/java/cz/cvut/kbss/termit/util/debounce/DebounceAspect.java +++ /dev/null @@ -1,234 +0,0 @@ -package cz.cvut.kbss.termit.util.debounce; - -import cz.cvut.kbss.termit.exception.TermItException; -import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.reflect.MethodSignature; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Scope; -import org.springframework.core.annotation.Order; -import org.springframework.expression.AccessException; -import org.springframework.expression.EvaluationContext; -import org.springframework.expression.ExpressionParser; -import org.springframework.expression.PropertyAccessor; -import org.springframework.expression.TypedValue; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.expression.spel.support.DataBindingMethodResolver; -import org.springframework.expression.spel.support.SimpleEvaluationContext; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.time.Instant; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.NavigableMap; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentSkipListMap; -import java.util.concurrent.Future; -import java.util.concurrent.FutureTask; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_SINGLETON; - -/** - * @implNote The aspect is configured in {@code spring-aop.xml}, this uses Spring AOP instead of AspectJ. - */ -@Order -@Scope(SCOPE_SINGLETON) -@Component("debounceAspect") -public class DebounceAspect { - - private static final Logger LOG = LoggerFactory.getLogger(DebounceAspect.class); - - private static final NavigableMap> store = new ConcurrentSkipListMap<>(); - - private static final Set debouncedThreads = ConcurrentHashMap.newKeySet(); - - private static final Duration TIMEOUT = Duration.ofSeconds(10); - - private final ExpressionParser parser = new SpelExpressionParser(); - - private final TaskScheduler taskScheduler; - - @Autowired - public DebounceAspect(TaskScheduler taskScheduler) { - this.taskScheduler = taskScheduler; - } - - private static void resolveParameters(Map map, MethodSignature signature, JoinPoint joinPoint) { - final String[] paramNames = signature.getParameterNames(); - final Object[] params = joinPoint.getArgs(); - - for (int i = 0; i < params.length; i++) { - map.putIfAbsent(paramNames[i], params[i]); - } - } - - private static EvaluationContext makeContext(JoinPoint joinPoint, Map parameters) { - return SimpleEvaluationContext.forPropertyAccessors(new MapPropertyAccessor<>(joinPoint.getTarget() - .getClass(), parameters)) - .withMethodResolvers(DataBindingMethodResolver.forInstanceMethodInvocation()) - .withRootObject(joinPoint.getTarget()).build(); - } - - private static @NotNull FutureTask getFutureTask(@NotNull ProceedingJoinPoint joinPoint, String key) { - final Supplier securityContext = SecurityContextHolder.getDeferredContext(); - final Class returnType = ((MethodSignature) joinPoint.getSignature()).getReturnType(); - final boolean resolveFuture = returnType.isAssignableFrom(Future.class); - - return new FutureTask<>(() -> { - final Long threadId = Thread.currentThread().getId(); - debouncedThreads.add(threadId); - LOG.trace("Running debounced task {}", key); - SecurityContextHolder.setContext(securityContext.get()); - try { - if (resolveFuture) { - Future future = (Future) joinPoint.proceed(); - if (future instanceof Runnable runnable) { - runnable.run(); - } - return future.get(); - } else { - return joinPoint.proceed(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw e; - } catch (Throwable e) { - throw new TermItException(e); - } finally { - SecurityContextHolder.clearContext(); - LOG.trace("Debounced task run finished {}", key); - debouncedThreads.remove(threadId); - } - }); - } - - /** - * @return future or null - * @throws TermItException when the target method throws - * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or {@link Future} - * @implNote Around advice configured in {@code spring-aop.xml} - */ - @SuppressWarnings("unused") - public @Nullable Object debounceMethodCall(@NotNull ProceedingJoinPoint joinPoint, - @NotNull Debounce debounceAnnotation) - throws Throwable { - - if (debouncedThreads.contains(Thread.currentThread().getId())) { - return joinPoint.proceed(); - } - - final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - - final String key = makeKey(joinPoint, debounceAnnotation); - LOG.trace("Debouncing task with key {}", key); - - if (!debounceAnnotation.clearGroup().isBlank()) { - store.tailMap(debounceAnnotation.clearGroup()).clear(); - } - - FutureTask task = getFutureTask(joinPoint, key); - - Object result = voidOrFuture(signature, task); - - if (store.containsKey(key)) { - boolean canceled = store.get(key).cancel(false); - if (canceled) { - LOG.trace("Old task canceled {}", key); - } - } - - store.put(key, taskScheduler.schedule(task, Instant.now().plus(TIMEOUT))); - - return result; - } - - private String makeKey(JoinPoint joinPoint, Debounce debounceAnnotation) throws IllegalCallerException { - final String identifier = constructIdentifier(joinPoint, debounceAnnotation.value()); - final String groupIdentifier = debounceAnnotation.group(); - - if (identifier == null) { - throw new IllegalCallerException("Identifier in Debounce annotation resolved to null"); - } - - return groupIdentifier + "-" + joinPoint.getSignature() + "-" + identifier; - } - - private @Nullable Object voidOrFuture(@NotNull MethodSignature signature, FutureTask task) - throws IllegalCallerException { - Class returnType = signature.getReturnType(); - if (returnType.isAssignableFrom(Future.class)) { - return task; - } - if (Void.TYPE.equals(returnType) || Void.class.equals(returnType)) { - return null; - } - throw new IllegalCallerException("Invalid return type for " + signature + " annotated with @Debounce, only Future or void allowed!"); - } - - private @Nullable String constructIdentifier(JoinPoint joinPoint, String expression) { - if (expression == null || expression.isBlank()) { - return ""; - } - - final Map parameters = new HashMap<>(); - resolveParameters(parameters, (MethodSignature) joinPoint.getSignature(), joinPoint); - assert !parameters.isEmpty(); - - final EvaluationContext context = makeContext(joinPoint, parameters); - - final List identifier = parser.parseExpression(expression).getValue(context, List.class); - assert identifier != null; - - return identifier.stream().map(Object::toString).collect(Collectors.joining("-")); - } - - /** - * Resolves properties to map values - * - * @param rootClass a class this accessor should accept as target's class - * @param map the map with values - */ - private record MapPropertyAccessor(Class rootClass, Map map) implements PropertyAccessor { - - @Override - public Class[] getSpecificTargetClasses() { - return new Class[]{rootClass, map.getClass()}; - } - - @Override - public boolean canRead(@NotNull EvaluationContext context, Object target, @NotNull String name) - throws AccessException { - return map.containsKey(name); - } - - @Override - public @NotNull TypedValue read(@NotNull EvaluationContext context, Object target, @NotNull String name) - throws AccessException { - return new TypedValue(map.get(name)); - } - - @Override - public boolean canWrite(@NotNull EvaluationContext context, Object target, @NotNull String name) - throws AccessException { - return false; - } - - @Override - public void write(@NotNull EvaluationContext context, Object target, @NotNull String name, Object newValue) - throws AccessException { - throw new AccessException("Unsupported operation"); - } - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java new file mode 100644 index 000000000..0cf2145b9 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java @@ -0,0 +1,61 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that calls to this method will be throttled & debounced. + * Meaning that the action will be executed on the first call of the method, + * then every next call which comes earlier then {@link ThrottleAspect#THROTTLE_THRESHOLD} + * will return a pending future which might be resolved by a newer future. + * Futures will be resolved once per {@link ThrottleAspect#THROTTLE_THRESHOLD} (+ duration to execute the future task). + *

    + * Every annotated method should be tested for throttling to ensure it has the desired effect. + *

    + * Method can't use any parameters that are part of persistent context, they need to be re-requested. + *

    + * Available only for methods returning {@code void}, {@link Void} and {@link ThrottledFuture}, + * method signature may be {@link java.util.concurrent.Future}, + * but the returned concrete object has to be {@link ThrottledFuture}, method call will throw otherwise! + *

    + * Note that returned future can be canceled (see {@link #clearGroup()}) + *

    + * Example implementation: + *

    + *  @Throttle(value = "{paramObj, anotherParam}")
    + *  public Future<String> myFunction(Object paramObj, Object anotherParam) {
    + *      return ThrottledFuture.of(() -> doStuff());
    + *  }
    + * 
    + * + * @implNote The annotation is being processed by {@link ThrottleAspect#throttledThreads} + * @see Debouncing and Throttling + * @see Throttling + debouncing image + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Throttle { + + /** + * @return The Spring-EL expression returning a List of Objects which will be used to construct the unique identifier + * for this throttled instance. In the expression, you have available method parameters. + */ + @NotNull String value() default ""; + + /** + * @return The group identifier to which this throttle belongs to. + * Used for canceling tasks with {@link #clearGroup()}. + * When there is a pending task with a group that is also a prefix for this group, this task will be canceled immediately. + */ + @NotNull String group() default ""; + + /** + * @return A prefix of a group that will be cleared on throttling. + * All pending tasks with a prefix of this value will be canceled in favor of this task. + */ + @NotNull String clearGroup() default ""; +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java new file mode 100644 index 000000000..de76edd1b --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -0,0 +1,340 @@ +package cz.cvut.kbss.termit.util.throttle; + +import cz.cvut.kbss.termit.exception.TermItException; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.Scope; +import org.springframework.core.annotation.Order; +import org.springframework.expression.AccessException; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.PropertyAccessor; +import org.springframework.expression.TypedValue; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.DataBindingMethodResolver; +import org.springframework.expression.spel.support.SimpleEvaluationContext; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.Future; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_SINGLETON; + +/** + * @implNote The aspect is configured in {@code spring-aop.xml}, this uses Spring AOP instead of AspectJ. + */ +@Order +@Scope(SCOPE_SINGLETON) +@Component("throttleAspect") +@Profile("!test") +public class ThrottleAspect { + + public static final Duration THROTTLE_THRESHOLD = Duration.ofSeconds(10); // TODO: config value + + private static final Logger LOG = LoggerFactory.getLogger(ThrottleAspect.class); + + private final Map> throttledFutures; + + private final Map lastRun; + + private final NavigableMap> scheduledFutures; + + /** + * synchronize before access + */ + private final Set throttledThreads = new HashSet<>(); + + private final ExpressionParser parser = new SpelExpressionParser(); + + private final TaskScheduler taskScheduler; + + private final Clock clock; + + @Autowired + public ThrottleAspect(TaskScheduler taskScheduler) { + this.taskScheduler = taskScheduler; + throttledFutures = new HashMap<>(); + lastRun = new HashMap<>(); + scheduledFutures = new TreeMap<>(); + clock = Clock.systemUTC(); // used by Instant.now() by default + } + + public ThrottleAspect(Map> throttledFutures, Map lastRun, + NavigableMap> scheduledFutures, TaskScheduler taskScheduler, + Clock clock) { + this.throttledFutures = throttledFutures; + this.lastRun = lastRun; + this.scheduledFutures = scheduledFutures; + this.taskScheduler = taskScheduler; + this.clock = clock; + } + + /** + * Maps parameter names from the method signature to their values from {@link JoinPoint#getArgs()} + * + * @param map to fill + * @param signature the method signature + * @param joinPoint the join point + */ + private static void resolveParameters(Map map, MethodSignature signature, JoinPoint joinPoint) { + final String[] paramNames = signature.getParameterNames(); + final Object[] params = joinPoint.getArgs(); + + for (int i = 0; i < params.length; i++) { + map.putIfAbsent(paramNames[i], params[i]); + } + } + + private static EvaluationContext makeContext(JoinPoint joinPoint, Map parameters) { + return SimpleEvaluationContext.forPropertyAccessors(new MapPropertyAccessor<>(joinPoint.getTarget() + .getClass(), parameters)) + .withMethodResolvers(DataBindingMethodResolver.forInstanceMethodInvocation()) + .withRootObject(joinPoint.getTarget()).build(); + } + + /** + * Use this method with caution, ensure that both throttled futures contain the same type! + */ + @SuppressWarnings({"unchecked"}) + private static ThrottledFuture transferTask(@NotNull ThrottledFuture source, + @NotNull ThrottledFuture target) { + return (ThrottledFuture) source.transfer(target); + } + + private @NotNull AbstractMap.SimpleImmutableEntry> getFutureTask( + @NotNull ProceedingJoinPoint joinPoint, String key, @NotNull ThrottledFuture future) + throws Throwable { + + final Supplier securityContext = SecurityContextHolder.getDeferredContext(); + final Class returnType = ((MethodSignature) joinPoint.getSignature()).getReturnType(); + final boolean isFuture = returnType.isAssignableFrom(ThrottledFuture.class); + + ThrottledFuture throttledFuture = future; + + // Sets the task to the future. + // If the annotated method returns throttled future, transfer the new task into the future + // replacing the old one. + // If the method does not return a throttled future, + // fill the future with a task which calls the annotated method returning the result + + // the future must contain the same type - ensured by accessing with the unique key + if (isFuture) { + ThrottledFuture throttledMethodFuture = (ThrottledFuture) joinPoint.proceed(); + // future acquired by key or a new future supplied, ensuring the same type + // ThrottledFuture#updateOther will create a new future when required + throttledFuture = transferTask(throttledMethodFuture, future); + } else { + future.update(() -> { + try { + return joinPoint.proceed(); + } catch (Throwable e) { + throw new TermItException(e); + } + }); + } + + // create a task which will be scheduled with executor + final Runnable toSchedule = () -> { + if (future.isCancelled() || future.isDone()) { + return; + } + // mark the thread as throttled + final Long threadId = Thread.currentThread().getId(); + throttledThreads.add(threadId); + LOG.trace("Running throttled task '{}'", key); + // restore the security context + SecurityContextHolder.setContext(securityContext.get()); + try { + // update last run timestamp + synchronized (lastRun) { + lastRun.put(key, Instant.now(clock)); + } + // fulfill the future + future.run(); + } finally { + // clear the security context + SecurityContextHolder.clearContext(); + LOG.trace("Throttled task run finished '{}'", key); + // remove throttled mark + throttledThreads.remove(threadId); + } + }; + + return new AbstractMap.SimpleImmutableEntry<>(toSchedule, throttledFuture); + } + + /** + * @return future or null + * @throws TermItException when the target method throws + * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or {@link Future} + * @implNote Around advice configured in {@code spring-aop.xml} + */ + public synchronized @Nullable Object throttleMethodCall(@NotNull ProceedingJoinPoint joinPoint, + @NotNull Throttle throttleAnnotation) + throws Throwable { + + // if the current thread is already executing a throttled code, we want to skip further throttling + if (throttledThreads.contains(Thread.currentThread().getId())) { + // proceed with method execution + Object result = joinPoint.proceed(); + if (result instanceof ThrottledFuture throttledFuture) { + // directly run throttled future + throttledFuture.run(); + return throttledFuture; + } + return result; + } + + final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + + // construct the throttle instance key + final String key = makeKey(joinPoint, throttleAnnotation); + LOG.trace("Throttling task with key '{}'", key); + + Map.Entry> ceiling = scheduledFutures.higherEntry(key); + if (!throttleAnnotation.group().isBlank() && ceiling != null) { + Future ceilingFuture = ceiling.getValue(); + if (!ceilingFuture.isDone() && !ceilingFuture.isCancelled()) { + LOG.trace("Throttling canceled due to scheduled ceiling task '{}'", ceiling.getKey()); + return ThrottledFuture.canceled(); + } + } + + // if there is a scheduled task and this throttled instance was executed in the last THROTTLE_THRESHOLD + // cancel the scheduled task + // -> the execution is further delayed + Future oldFuture = scheduledFutures.get(key); + boolean throttleNotExpired = lastRun.getOrDefault(key, Instant.EPOCH) + .isAfter(Instant.now(clock).minus(THROTTLE_THRESHOLD)); + if (oldFuture != null && throttleNotExpired) { + oldFuture.cancel(false); + } + + // acquire a throttled future from a map, or make a new one + ThrottledFuture oldThrottledFuture = throttledFutures.getOrDefault(key, new ThrottledFuture<>()); + + AbstractMap.SimpleImmutableEntry> entry = getFutureTask(joinPoint, key, oldThrottledFuture); + Runnable task = entry.getKey(); + ThrottledFuture future = entry.getValue(); + // update the throttled future in the map + throttledFutures.put(key, future); + + Object result = voidOrFuture(signature, key, future); + + clearGroup(throttleAnnotation); + + if (oldFuture == null || oldFuture.isDone() || oldFuture.isCancelled()) { + Future scheduled = (Future) taskScheduler.schedule(task, Instant.now(clock) + .plus(THROTTLE_THRESHOLD)); + scheduledFutures.put(key, scheduled); + } + + return result; + } + + private void clearGroup(Throttle throttleAnnotation) { + if (!throttleAnnotation.clearGroup().isBlank()) { + Map> toClear = scheduledFutures.tailMap(throttleAnnotation.clearGroup()); + toClear.forEach((k, f) -> f.cancel(false)); + toClear.clear(); + } + } + + private String makeKey(JoinPoint joinPoint, Throttle throttleAnnotation) throws IllegalCallerException { + final String identifier = constructIdentifier(joinPoint, throttleAnnotation.value()); + final String groupIdentifier = throttleAnnotation.group(); + + if (identifier == null) { + throw new IllegalCallerException("Identifier in Debounce annotation resolved to null"); + } + + return groupIdentifier + "-" + joinPoint.getSignature().toShortString() + "-" + identifier; + } + + private @Nullable Object voidOrFuture(@NotNull MethodSignature signature, String key, + ThrottledFuture future) + throws IllegalCallerException { + Class returnType = signature.getReturnType(); + if (returnType.isAssignableFrom(Future.class)) { + return future; + } + if (Void.TYPE.equals(returnType) || Void.class.equals(returnType)) { + return null; + } + throw new IllegalCallerException("Invalid return type for " + signature + " annotated with @Debounce, only Future or void allowed!"); + } + + private @Nullable String constructIdentifier(JoinPoint joinPoint, String expression) { + if (expression == null || expression.isBlank()) { + return ""; + } + + final Map parameters = new HashMap<>(); + resolveParameters(parameters, (MethodSignature) joinPoint.getSignature(), joinPoint); + assert !parameters.isEmpty(); + + final EvaluationContext context = makeContext(joinPoint, parameters); + + final List identifier = parser.parseExpression(expression).getValue(context, List.class); + assert identifier != null; + + return identifier.stream().map(Object::toString).collect(Collectors.joining("-")); + } + + /** + * Resolves properties to map values + * + * @param rootClass a class this accessor should accept as target's class + * @param map the map with values + */ + private record MapPropertyAccessor(Class rootClass, Map map) implements PropertyAccessor { + + @Override + public Class[] getSpecificTargetClasses() { + return new Class[]{rootClass, map.getClass()}; + } + + @Override + public boolean canRead(@NotNull EvaluationContext context, Object target, @NotNull String name) { + return map.containsKey(name); + } + + @Override + public @NotNull TypedValue read(@NotNull EvaluationContext context, Object target, @NotNull String name) { + return new TypedValue(map.get(name)); + } + + @Override + public boolean canWrite(@NotNull EvaluationContext context, Object target, @NotNull String name) { + return false; + } + + @Override + public void write(@NotNull EvaluationContext context, Object target, @NotNull String name, Object newValue) + throws AccessException { + throw new AccessException("Unsupported operation"); + } + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java new file mode 100644 index 000000000..5c129390e --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -0,0 +1,131 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; + +public class ThrottledFuture implements Future { + + private final Object lock = new Object(); + + private final CompletableFuture future; + + private @Nullable Supplier task; + + /** + * Access only with acquired {@link #lock} + */ + private boolean completing = false; + + private ThrottledFuture(@NotNull final Supplier task) { + this.task = task; + future = new CompletableFuture<>(); + } + + protected ThrottledFuture() { + future = new CompletableFuture<>(); + } + + public static ThrottledFuture of(@NotNull final Supplier supplier) { + return new ThrottledFuture<>(supplier); + } + + public static ThrottledFuture of(@NotNull final Runnable runnable) { + return new ThrottledFuture<>(() -> { + runnable.run(); + return null; + }); + } + + /** + * @return already canceled future + */ + public static ThrottledFuture canceled() { + ThrottledFuture f = new ThrottledFuture<>(); + f.cancel(true); + return f; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return future.cancel(mayInterruptIfRunning); + } + + @Override + public boolean isCancelled() { + return future.isCancelled(); + } + + @Override + public boolean isDone() { + return future.isDone(); + } + + @Override + public T get() throws InterruptedException, ExecutionException { + return future.get(); + } + + @Override + public T get(long timeout, @NotNull TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return future.get(timeout, unit); + } + + /** + * @param task the new task + * @return If the current task is already running, was canceled or already completed, returns a new future for the given task. + * Otherwise, replaces the current task and returns this. + */ + protected ThrottledFuture update(Supplier task) { + synchronized (lock) { + if (completing || future.isCancelled() || future.isDone()) { + return ThrottledFuture.of(task); + } + this.task = task; + return this; + } + } + + + /** + * Transfers the task from this object to the specified {@code throttledFuture}. + * If the current task is already running, canceled or completed, this method has no effect. + * + * @param target the future to update + * @return target when current future is already being executed, was canceled or completed. + * New future when the target is being executed, was canceled or completed. + */ + protected ThrottledFuture transfer(ThrottledFuture target) { + synchronized (lock) { + if (completing || future.isCancelled() || future.isDone()) { + return target; + } + + ThrottledFuture result = target.update(this.task); + this.task = null; + return result; + } + } + + protected void run() { + synchronized (lock) { + if (completing || future.isCancelled() || future.isDone()) { + return; + } + completing = true; + } + + if (task != null) { + future.complete(task.get()); + } else { + future.complete(null); + } + } +} diff --git a/src/main/resources/spring-aop.xml b/src/main/resources/spring-aop.xml index 66217ad60..0bf22ed53 100644 --- a/src/main/resources/spring-aop.xml +++ b/src/main/resources/spring-aop.xml @@ -4,17 +4,18 @@ xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation=" http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd - http://www.springframework.org/schema/aop https://www.springframework.org/schema/aop/spring-aop.xsd"> + http://www.springframework.org/schema/aop https://www.springframework.org/schema/aop/spring-aop.xsd" + profile="!test"> AOP related definitions - - + + + pointcut="@annotation(throttleAnnotation)" + method="throttleMethodCall"/> diff --git a/src/test/java/cz/cvut/kbss/termit/environment/util/MockedFuture.java b/src/test/java/cz/cvut/kbss/termit/environment/util/MockedFuture.java new file mode 100644 index 000000000..61987c37b --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/environment/util/MockedFuture.java @@ -0,0 +1,57 @@ +package cz.cvut.kbss.termit.environment.util; + +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Utility class implementing future interface + */ +public class MockedFuture implements Future { + + private final T result; + + private boolean cancelled; + + private boolean done; + + public MockedFuture(T result) { + this.result = result; + this.done = true; + this.cancelled = false; + } + + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + if (done) { + return false; + } + this.cancelled = true; + return true; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public boolean isDone() { + return done; + } + + @Override + public T get() throws InterruptedException, ExecutionException { + return result; + } + + @Override + public T get(long timeout, @NotNull TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return result; + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java index da7f09793..ad8ac95e8 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java @@ -33,7 +33,6 @@ import cz.cvut.kbss.termit.model.resource.Document; import cz.cvut.kbss.termit.model.resource.File; import cz.cvut.kbss.termit.model.selector.TextQuoteSelector; -import cz.cvut.kbss.termit.persistence.dao.util.ScheduledContextRemover; import cz.cvut.kbss.termit.util.Vocabulary; import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.vocabulary.RDFS; @@ -73,8 +72,8 @@ class TermOccurrenceDaoTest extends BaseDaoTestRunner { @Autowired private EntityManager em; - @Autowired - private ScheduledContextRemover contextRemover; +// @Autowired +// private ScheduledContextRemover contextRemover; @Autowired private TermOccurrenceDao sut; @@ -270,7 +269,7 @@ void removeAllRemovesSuggestedAndConfirmedOccurrences() { }))); transactional(() -> { sut.removeAll(file); - contextRemover.runContextRemoval(); +// contextRemover.runContextRemoval(); }); assertTrue(sut.findAllTargeting(file).isEmpty()); assertFalse(em.createNativeQuery("ASK { ?x a ?termOccurrence . }", Boolean.class).setParameter("termOccurrence", @@ -292,7 +291,7 @@ void removeAllRemovesAlsoOccurrenceTargets() { }))); transactional(() -> { sut.removeAll(file); - contextRemover.runContextRemoval(); +// contextRemover.runContextRemoval(); }); assertFalse(em.createNativeQuery("ASK { ?x a ?target . }", Boolean.class).setParameter("target", diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java index 5655a6011..5d133df7f 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java @@ -334,7 +334,7 @@ void getTransitivelyImportedVocabulariesReturnsAllImportedVocabulariesForVocabul em.persist(transitiveVocabulary, descriptorFactory.vocabularyDescriptor(transitiveVocabulary)); }); - final Collection result = sut.getTransitivelyImportedVocabularies(subjectVocabulary); + final Collection result = sut.getTransitivelyImportedVocabularies(subjectVocabulary.getUri()); assertEquals(3, result.size()); assertTrue(result.contains(importedVocabularyOne.getUri())); assertTrue(result.contains(importedVocabularyTwo.getUri())); diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemoverTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemoverTest.java index cb2a78a92..7a0757b45 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemoverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemoverTest.java @@ -1,48 +1,37 @@ package cz.cvut.kbss.termit.persistence.dao.util; -import cz.cvut.kbss.jopa.model.EntityManager; -import cz.cvut.kbss.jopa.vocabulary.RDFS; -import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.persistence.dao.BaseDaoTestRunner; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -import java.net.URI; -import java.util.HashSet; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertFalse; class ScheduledContextRemoverTest extends BaseDaoTestRunner { - - @Autowired - private EntityManager em; - - @Autowired - private ScheduledContextRemover sut; - - @Test - void runContextRemovalDropsContextsRegisteredForRemoval() { - final Set graphs = generateGraphs(); - graphs.forEach(sut::scheduleForRemoval); - - sut.runContextRemoval(); - graphs.forEach(g -> assertFalse( - em.createNativeQuery("ASK { ?g ?y ?z . }", Boolean.class).setParameter("g", g).getSingleResult())); - } - - private Set generateGraphs() { - final Set result = new HashSet<>(); - transactional(() -> { - for (int i = 0; i < 5; i++) { - final URI graphUri = Generator.generateUri(); - em.createNativeQuery("INSERT DATA { GRAPH ?g { ?g a ?type } }", Void.class) - .setParameter("g", graphUri) - .setParameter("type", URI.create(RDFS.RESOURCE)) - .executeUpdate(); - result.add(graphUri); - } - }); - return result; - } +// +// @Autowired +// private EntityManager em; +// +// @Autowired +// private ScheduledContextRemover sut; +// +// @Test +// void runContextRemovalDropsContextsRegisteredForRemoval() { +// final Set graphs = generateGraphs(); +// graphs.forEach(sut::scheduleForRemoval); +// +// sut.runContextRemoval(); +// graphs.forEach(g -> assertFalse( +// em.createNativeQuery("ASK { ?g ?y ?z . }", Boolean.class).setParameter("g", g).getSingleResult())); +// } +// +// private Set generateGraphs() { +// final Set result = new HashSet<>(); +// transactional(() -> { +// for (int i = 0; i < 5; i++) { +// final URI graphUri = Generator.generateUri(); +// em.createNativeQuery("INSERT DATA { GRAPH ?g { ?g a ?type } }", Void.class) +// .setParameter("g", graphUri) +// .setParameter("type", URI.create(RDFS.RESOURCE)) +// .executeUpdate(); +// result.add(graphUri); +// } +// }); +// return result; +// } } diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index 0d1c7444d..9458024f5 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -37,9 +37,11 @@ import cz.cvut.kbss.termit.rest.handler.ErrorInfo; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.VocabularyService; +import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; +import cz.cvut.kbss.termit.environment.util.MockedFuture; import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java index 2a4781da5..139307564 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java @@ -214,6 +214,7 @@ void persistUsesRepositoryServiceToPersistTermAsChildOfSpecifiedParentTerm() { void updateUsesRepositoryServiceToUpdateTerm() { final Term term = generateTermWithId(); when(termRepositoryService.findRequired(term.getUri())).thenReturn(term); + when(termRepositoryService.update(term)).thenReturn(term); sut.update(term); verify(termRepositoryService).update(term); } @@ -317,20 +318,19 @@ void runTextAnalysisInvokesTextAnalysisOnSpecifiedTerm() { @Test void persistChildInvokesTextAnalysisOnPersistedChildTerm() { - when(vocabularyContextMapper.getVocabularyContext(vocabulary.getUri())).thenReturn(vocabulary.getUri()); + when(vocabularyService.findRequired(vocabulary.getUri())).thenReturn(vocabulary); final Term parent = generateTermWithId(); parent.setVocabulary(vocabulary.getUri()); final Term childToPersist = generateTermWithId(); sut.persistChild(childToPersist, parent); - verify(textAnalysisService).analyzeTermDefinition(childToPersist, parent.getVocabulary()); + verify(vocabularyService).runTextAnalysisOnAllTerms(vocabulary); } @Test void persistRootInvokesTextAnalysisOnPersistedRootTerm() { - when(vocabularyContextMapper.getVocabularyContext(vocabulary.getUri())).thenReturn(vocabulary.getUri()); final Term toPersist = generateTermWithId(); sut.persistRoot(toPersist, vocabulary); - verify(textAnalysisService).analyzeTermDefinition(toPersist, vocabulary.getUri()); + verify(vocabularyService).runTextAnalysisOnAllTerms(vocabulary); } @Test @@ -338,14 +338,31 @@ void updateInvokesTextAnalysisOnUpdatedTerm() { when(vocabularyContextMapper.getVocabularyContext(vocabulary.getUri())).thenReturn(vocabulary.getUri()); final Term original = generateTermWithId(vocabulary.getUri()); final Term toUpdate = new Term(original.getUri()); + toUpdate.setLabel(original.getLabel()); final String newDefinition = "This term has acquired a new definition"; toUpdate.setVocabulary(vocabulary.getUri()); when(termRepositoryService.findRequired(toUpdate.getUri())).thenReturn(original); toUpdate.setDefinition(MultilingualString.create(newDefinition, Environment.LANGUAGE)); + when(termRepositoryService.update(toUpdate)).thenReturn(toUpdate); sut.update(toUpdate); verify(textAnalysisService).analyzeTermDefinition(toUpdate, toUpdate.getVocabulary()); } + @Test + void updateOfTermLabelInvokesTextAnalysisOnAllTerms() { + final Term original = generateTermWithId(vocabulary.getUri()); + final Term toUpdate = new Term(original.getUri()); + toUpdate.setLabel(MultilingualString.create("new Label", Environment.LANGUAGE)); + final String newDefinition = "This term has acquired a new definition"; + toUpdate.setVocabulary(vocabulary.getUri()); + toUpdate.setDefinition(MultilingualString.create(newDefinition, Environment.LANGUAGE)); + when(termRepositoryService.findRequired(toUpdate.getUri())).thenReturn(original); + when(termRepositoryService.update(toUpdate)).thenReturn(toUpdate); + when(vocabularyService.getReference(vocabulary.getUri())).thenReturn(vocabulary); + sut.update(toUpdate); + verify(vocabularyService).runTextAnalysisOnAllTerms(vocabulary); + } + @Test void setTermDefinitionSourceSetsTermOnDefinitionAndPersistsIt() { final Term term = Generator.generateTermWithId(); @@ -456,7 +473,6 @@ void persistChildInvokesTextAnalysisOnAllTermsInParentTermVocabulary() { parent.setVocabulary(vocabulary.getUri()); final Term childToPersist = generateTermWithId(); when(vocabularyService.findRequired(vocabulary.getUri())).thenReturn(vocabulary); - when(vocabularyContextMapper.getVocabularyContext(vocabulary.getUri())).thenReturn(vocabulary.getUri()); sut.persistChild(childToPersist, parent); final InOrder inOrder = inOrder(termRepositoryService, vocabularyService); @@ -474,6 +490,7 @@ void updateInvokesTextAnalysisOnAllTermsInTermsVocabularyWhenLabelHasChanged() { update.setVocabulary(vocabulary.getUri()); when(termRepositoryService.findRequired(original.getUri())).thenReturn(original); when(vocabularyService.getReference(vocabulary.getUri())).thenReturn(vocabulary); + when(termRepositoryService.update(update)).thenReturn(update); update.getLabel().set(Environment.LANGUAGE, "updatedLabel"); sut.update(update); @@ -637,6 +654,7 @@ void updateVerifiesThatStateExistsTermState() { update.setDescription(new MultilingualString(original.getDescription().getValue())); update.setVocabulary(vocabulary.getUri()); update.setState(Generator.randomItem(Generator.TERM_STATES)); + when(termRepositoryService.update(update)).thenReturn(update); sut.update(update); final InOrder inOrder = inOrder(languageService, termRepositoryService); inOrder.verify(languageService).verifyStateExists(update.getState()); diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java index 151796815..f08ecb92c 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java @@ -35,8 +35,8 @@ import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper; import cz.cvut.kbss.termit.persistence.snapshot.SnapshotCreator; import cz.cvut.kbss.termit.service.business.AccessControlListService; +import cz.cvut.kbss.termit.service.business.TermService; import cz.cvut.kbss.termit.service.business.VocabularyService; -import cz.cvut.kbss.termit.service.business.async.AsyncTermService; import cz.cvut.kbss.termit.service.export.ExportFormat; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.util.Configuration; @@ -57,6 +57,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.File; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; @@ -72,6 +73,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -81,7 +83,7 @@ class VocabularyServiceTest { @Mock - private AsyncTermService termService; + TermService termService; @Mock private VocabularyRepositoryService repositoryService; @@ -121,9 +123,12 @@ void runTextAnalysisOnAllTermsInvokesTextAnalysisOnAllTermsInVocabulary() { when(termService.findAll(vocabulary)).thenReturn(terms); when(contextMapper.getVocabularyContext(vocabulary.getUri())).thenReturn(vocabulary.getUri()); when(repositoryService.getTransitivelyImportedVocabularies(vocabulary)).thenReturn(Collections.emptyList()); + when(repositoryService.findRequired(vocabulary.getUri())).thenReturn(vocabulary); sut.runTextAnalysisOnAllTerms(vocabulary); - verify(termService).asyncAnalyzeTermDefinitions(Map.of(termOne, vocabulary.getUri(), - termTwo, vocabulary.getUri())); + Map expected = Map.of(termOne, vocabulary.getUri(), termTwo, vocabulary.getUri()); + for(final Map.Entry entry : expected.entrySet()) { + verify(termService).analyzeTermDefinition(entry.getKey(), entry.getValue()); + } } @Test @@ -136,7 +141,8 @@ void runTextAnalysisOnAllTermsInvokesTextAnalysisOnAllVocabularies() { when(contextMapper.getVocabularyContext(v.getUri())).thenReturn(v.getUri()); when(termService.findAll(v)).thenReturn(Collections.singletonList(new TermDto(term))); sut.runTextAnalysisOnAllVocabularies(); - verify(termService).asyncAnalyzeTermDefinitions(Map.of(term, v.getUri())); + + verify(termService).analyzeTermDefinition(term, v.getUri()); } @Test diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedFutureTask.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedFutureTask.java new file mode 100644 index 000000000..dc5678713 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedFutureTask.java @@ -0,0 +1,38 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.Callable; +import java.util.concurrent.Delayed; +import java.util.concurrent.FutureTask; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public class MockedFutureTask extends FutureTask implements ScheduledFuture { + + private final Callable callable; + + public MockedFutureTask(@NotNull Callable callable) { + super(callable); + this.callable = callable; + } + + public MockedFutureTask(@NotNull Runnable runnable, T result) { + super(runnable, result); + this.callable = null; + } + + public Callable getCallable() { + return callable; + } + + @Override + public long getDelay(@NotNull TimeUnit unit) { + return 0; + } + + @Override + public int compareTo(@NotNull Delayed o) { + return 0; + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java new file mode 100644 index 000000000..e03aaebcf --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java @@ -0,0 +1,87 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.aspectj.lang.reflect.MethodSignature; + +import java.lang.reflect.Method; + +public class MockedMethodSignature implements MethodSignature { + + private Class returnType; + + private Class[] parameterTypes; + + private String[] parameterNames; + + public MockedMethodSignature(Class returnType, Class[] parameterTypes, String[] parameterNames) { + this.returnType = returnType; + this.parameterTypes = parameterTypes; + this.parameterNames = parameterNames; + } + + @Override + public Class getReturnType() { + return returnType; + } + + @Override + public Method getMethod() { + return null; + } + + @Override + public Class[] getParameterTypes() { + return parameterTypes; + } + + @Override + public String[] getParameterNames() { + return parameterNames; + } + + @Override + public Class[] getExceptionTypes() { + return new Class[0]; + } + + @Override + public String toShortString() { + return "shortMethodSignatureString"; + } + + @Override + public String toLongString() { + return "longMethodSignatureString"; + } + + @Override + public String getName() { + return "testingMethodName"; + } + + @Override + public int getModifiers() { + return 0; + } + + @Override + public Class getDeclaringType() { + return null; + } + + @Override + public String getDeclaringTypeName() { + return ""; + } + + public void setReturnType(Class returnType) { + this.returnType = returnType; + } + + public void setParameterTypes(Class[] parameterTypes) { + this.parameterTypes = parameterTypes; + } + + public void setParameterNames(String[] parameterNames) { + this.parameterNames = parameterNames; + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java new file mode 100644 index 000000000..59b1c84c9 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java @@ -0,0 +1,52 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; + +public class MockedThrottle implements Throttle { + + private String value; + + private String group; + + private String clearGroup; + + public MockedThrottle(String value, String group, String clearGroup) { + this.value = value; + this.group = group; + this.clearGroup = clearGroup; + } + + @Override + public @NotNull String value() { + return value; + } + + @Override + public @NotNull String group() { + return group; + } + + @Override + public @NotNull String clearGroup() { + return clearGroup; + } + + @Override + public Class annotationType() { + return Throttle.class; + } + + public void setValue(String value) { + this.value = value; + } + + public void setGroup(String group) { + this.group = group; + } + + public void setClearGroup(String clearGroup) { + this.clearGroup = clearGroup; + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java new file mode 100644 index 000000000..75c1df176 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -0,0 +1,203 @@ +package cz.cvut.kbss.termit.util.throttle; + +import com.vladsch.flexmark.util.collection.OrderedMap; +import org.aspectj.lang.ProceedingJoinPoint; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.scheduling.TaskScheduler; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.AbstractMap; +import java.util.Map; +import java.util.NavigableMap; +import java.util.TreeMap; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Supplier; + +import static cz.cvut.kbss.termit.util.throttle.ThrottleAspect.THROTTLE_THRESHOLD; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +class ThrottleAspectTest { + + OrderedMap> throttledFutures; + + OrderedMap lastRun; + + NavigableMap> scheduledFutures; + + TaskScheduler taskScheduler; + + OrderedMap taskSchedulerTasks; + + ThrottleAspect sut; + + Throttle throttleA; + + Throttle throttleB; + + MockedMethodSignature signatureA; + + MockedMethodSignature signatureB; + + ProceedingJoinPoint joinPointA; + + ProceedingJoinPoint joinPointB; + + Clock clock = Clock.fixed(Instant.now(), ZoneId.of("UTC")); + + void mockA() throws Throwable { + joinPointA = mock(ProceedingJoinPoint.class); + when(joinPointA.proceed()).thenReturn(null); + signatureA = spy(new MockedMethodSignature(Void.TYPE, new Class[]{Object.class, Object.class}, new String[]{ + "paramA", "paramB"})); + when(joinPointA.getSignature()).thenReturn(signatureA); + when(joinPointA.getArgs()).thenReturn(new Object[]{new Object(), new Object()}); + when(joinPointA.getTarget()).thenReturn(this); + + throttleA = new MockedThrottle("'string literal'", "my.testing", ""); + } + + void mockB() throws Throwable { + joinPointB = mock(ProceedingJoinPoint.class); + when(joinPointB.proceed()).thenReturn(null); + signatureB = spy(new MockedMethodSignature(Void.class, new Class[]{Map.class}, new String[]{"paramName"})); + when(joinPointB.getSignature()).thenReturn(signatureB); + + when(joinPointB.getArgs()).thenReturn(new Object[]{Map.of("first", "firstValue", "second", "secondValue")}); + when(joinPointB.getTarget()).thenReturn(this); + + + throttleB = new MockedThrottle("{param.get('second'), param.get('first')}", "my.testing.group", ""); + } + + @BeforeEach + void beforeEach() throws Throwable { + mockA(); + mockB(); + + taskSchedulerTasks = new OrderedMap<>(); + + taskScheduler = mock(TaskScheduler.class); + when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).then(invocation -> { + taskSchedulerTasks.put(invocation.getArgument(0, Runnable.class), invocation.getArgument(1, Instant.class)); + System.out.println("Scheduled task at " + invocation.getArgument(1, Instant.class)); + return new MockedFutureTask<>(Executors.callable(invocation.getArgument(0, Runnable.class))); + }); + + throttledFutures = new OrderedMap<>(); + lastRun = new OrderedMap<>(); + scheduledFutures = new TreeMap<>(); + + Clock mockedClock = mock(Clock.class); + when(mockedClock.instant()).then(invocation -> getInstant()); + + sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock); + } + + Instant getInstant() { + return clock.instant().truncatedTo(ChronoUnit.SECONDS); + } + + void addSecond() { + clock = Clock.offset(clock, Duration.ofSeconds(1)); + } + + @Test + void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { + final String[] params = new String[]{"param1", "param2", "param3", "param4", "param5", "param6"}; + // define a future as the return type of the method + doReturn(Future.class).when(signatureA).getReturnType(); + + final Supplier methodTask = () -> "method result"; + final Supplier anotherMethodResult = () -> "another method result"; + + final ThrottledFuture methodFuture = new ThrottledFuture<>(); + methodFuture.update(methodTask); + + // for each method call, make new future with "another method task" + doAnswer(invocation -> new ThrottledFuture().update(anotherMethodResult)).when(joinPointA).proceed(); + + final Instant firstCall = getInstant(); + // simulate first call with params 0 & 1 + when(joinPointA.getArgs()).thenReturn(new Object[]{params[0], params[1]}); + final Object result1 = sut.throttleMethodCall(joinPointA, throttleA); + + addSecond(); + // simulate second call + when(joinPointA.getArgs()).thenReturn(new Object[]{params[2], params[3]}); + final Object result2 = sut.throttleMethodCall(joinPointA, throttleA); + addSecond(); + + // change the return value of the method to the prepared future + doReturn(methodFuture).when(joinPointA).proceed(); + + // simulate last call + when(joinPointA.getArgs()).thenReturn(new Object[]{params[4], params[5]}); + final Object result3 = sut.throttleMethodCall(joinPointA, throttleA); + + // all three calls returned the same future + // this ensures that calls are actually batched and single result + // satisfies all batched calls + assertEquals(result1, result2); + assertEquals(result1, result3); + + // there should be only a single scheduled future + assertEquals(1, scheduledFutures.size()); + assertEquals(1, taskSchedulerTasks.size()); + + final Instant scheduledAt = taskSchedulerTasks.getValue(0); + final Runnable scheduledTask = taskSchedulerTasks.getKey(0); + assertNotNull(scheduledAt); + assertNotNull(scheduledTask); + // the task should be scheduled at the first call + assertEquals(firstCall.plus(THROTTLE_THRESHOLD), scheduledAt); + + final ThrottledFuture future = throttledFutures.getValue(0); + assertNotNull(future); + + // fulfill the future + scheduledTask.run(); + // the future should be completed + assertTrue(future.isDone()); + // check that the task in the future is from the last method call + assertEquals(methodTask.get(), future.get()); + } + + @Test + void schedulesNewFutureWhenTheOldOneIsCompleted() throws Throwable { + sut.throttleMethodCall(joinPointA, throttleA); + addSecond(); + + final AbstractMap.SimpleImmutableEntry> firstEntry = new AbstractMap.SimpleImmutableEntry<>(scheduledFutures.firstEntry()); + final Runnable firstTask = taskSchedulerTasks.getKey(0); + assertNotNull(firstTask); + firstTask.run(); + + sut.throttleMethodCall(joinPointA, throttleA); + addSecond(); + + assertEquals(1, scheduledFutures.size()); + assertEquals(2, taskSchedulerTasks.size()); + final Future secondFuture = scheduledFutures.get(firstEntry.getKey()); + assertNotEquals(firstEntry.getValue(), secondFuture); + assertTrue(firstEntry.getValue().isDone()); + assertFalse(secondFuture.isDone()); + assertFalse(secondFuture.isCancelled()); + } + +} From 497cd7ce19931205a62a5cc1c596171e5b16ec85 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 21 Aug 2024 11:02:51 +0200 Subject: [PATCH 080/150] Throttle aspect & tests --- .../exception/ThrottleAspectException.java | 17 ++ .../service/business/VocabularyService.java | 3 +- .../cz/cvut/kbss/termit/util/Constants.java | 11 + .../termit/util/throttle/ComparablePair.java | 53 +++++ .../kbss/termit/util/throttle/Throttle.java | 40 ++-- .../termit/util/throttle/ThrottleAspect.java | 214 ++++++++++++------ .../util/throttle/MockedMethodSignature.java | 6 +- .../termit/util/throttle/MockedThrottle.java | 14 +- .../util/throttle/ThrottleAspectTest.java | 211 +++++++++++++++-- 9 files changed, 450 insertions(+), 119 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/exception/ThrottleAspectException.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/ComparablePair.java diff --git a/src/main/java/cz/cvut/kbss/termit/exception/ThrottleAspectException.java b/src/main/java/cz/cvut/kbss/termit/exception/ThrottleAspectException.java new file mode 100644 index 000000000..2f8270bf7 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/exception/ThrottleAspectException.java @@ -0,0 +1,17 @@ +package cz.cvut.kbss.termit.exception; + +/** + * Indicates wrong usage of {@link cz.cvut.kbss.termit.util.throttle.Throttle} annotation. + * + * @see cz.cvut.kbss.termit.util.throttle.ThrottleAspect + */ +public class ThrottleAspectException extends TermItException { + + public ThrottleAspectException(String message) { + super(message); + } + + public ThrottleAspectException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index d33390eb3..bb55cee76 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -312,8 +312,7 @@ public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { /** * Runs text analysis on definitions of all terms in all vocabularies. */ - @Throttle(group = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY, - clearGroup = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY) + @Throttle(group = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY) @Transactional public void runTextAnalysisOnAllVocabularies() { LOG.debug("Analyzing definitions of all terms in all vocabularies."); diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index 5d7ead6a9..a85a25972 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -22,6 +22,7 @@ import org.springframework.data.domain.Pageable; import java.net.URI; +import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -46,6 +47,16 @@ public class Constants { */ public static final String REST_MAPPING_PATH = "/rest"; + /** + * The amount of time in which calls of methods + * with {@link cz.cvut.kbss.termit.util.throttle.Throttle} annotation + * should be merged. + * + * @see cz.cvut.kbss.termit.util.throttle.Throttle + * @see cz.cvut.kbss.termit.util.throttle.ThrottleAspect + */ + public static final Duration THROTTLE_THRESHOLD = Duration.ofSeconds(10); + /** * Default page size. *

    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ComparablePair.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ComparablePair.java new file mode 100644 index 000000000..d02f14f6e --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ComparablePair.java @@ -0,0 +1,53 @@ +package cz.cvut.kbss.termit.util.throttle; + + +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * First compares the first value, if they are equal, compares the second value. + */ +public class ComparablePair, V extends Comparable> + implements Comparable> { + + private final T first; + + private final V second; + + public ComparablePair(T first, V second) { + this.first = first; + this.second = second; + } + + public T getFirst() { + return first; + } + + public V getSecond() { + return second; + } + + @Override + public int compareTo(@NotNull ComparablePair o) { + final int firstComparison = this.first.compareTo(o.first); + if (firstComparison != 0) { + return firstComparison; + } + return this.second.compareTo(o.second); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ComparablePair that = (ComparablePair) o; + return Objects.equals(first, that.first) && Objects.equals(second, that.second); + } + + @Override + public int hashCode() { + return Objects.hash(first, second); + } +} + diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java index 0cf2145b9..6179bf404 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java @@ -20,17 +20,18 @@ *

    * Available only for methods returning {@code void}, {@link Void} and {@link ThrottledFuture}, * method signature may be {@link java.util.concurrent.Future}, + * or another type assignable from {@link ThrottledFuture}, * but the returned concrete object has to be {@link ThrottledFuture}, method call will throw otherwise! *

    * Note that returned future can be canceled (see {@link #clearGroup()}) *

    * Example implementation: - *

    - *  @Throttle(value = "{paramObj, anotherParam}")
    + * 
    
    + *  {@code @}Throttle(value = "{paramObj, anotherParam}")
      *  public Future<String> myFunction(Object paramObj, Object anotherParam) {
      *      return ThrottledFuture.of(() -> doStuff());
      *  }
    - * 
    + *
    * * @implNote The annotation is being processed by {@link ThrottleAspect#throttledThreads} * @see Debouncing and Throttling @@ -41,21 +42,32 @@ public @interface Throttle { /** - * @return The Spring-EL expression returning a List of Objects which will be used to construct the unique identifier - * for this throttled instance. In the expression, you have available method parameters. + * The Spring-EL expression returning a List of Objects which will be used to construct the unique identifier + * for this throttled instance. + *

    + * In the expression, you have available method parameters. */ @NotNull String value() default ""; /** - * @return The group identifier to which this throttle belongs to. - * Used for canceling tasks with {@link #clearGroup()}. - * When there is a pending task with a group that is also a prefix for this group, this task will be canceled immediately. + * The Spring-EL expression returning group identifier (String) to which this throttle belongs. + *

    + * When there is a pending task P with a group + * that is also a prefix for a group of a new task N, + * the new task N will be canceled immediately. + * The group of the task P is lower than the group of the task N. + *

    + * When a task with lower group is scheduled, all scheduled tasks with higher groups are canceled. + *

    + * Example: + *

    +     *     new task A with group "my.group.task1" is scheduled
    +     *     new task B with group "my.group.task1.subtask" wants to be scheduled
    +     *        -> task B is canceled immediately (task A with lower group is already pending)
    +     *     new task C with group "my.group" is scheduled
    +     *        -> task A is canceled as the task C has lower group than A
    +     * 
    + * Blank string disables any group processing. */ @NotNull String group() default ""; - - /** - * @return A prefix of a group that will be cleared on throttling. - * All pending tasks with a prefix of this value will be canceled in favor of this task. - */ - @NotNull String clearGroup() default ""; } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index de76edd1b..b306e0620 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -1,6 +1,7 @@ package cz.cvut.kbss.termit.util.throttle; import cz.cvut.kbss.termit.exception.TermItException; +import cz.cvut.kbss.termit.exception.ThrottleAspectException; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; @@ -14,6 +15,8 @@ import org.springframework.core.annotation.Order; import org.springframework.expression.AccessException; import org.springframework.expression.EvaluationContext; +import org.springframework.expression.EvaluationException; +import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; import org.springframework.expression.PropertyAccessor; import org.springframework.expression.TypedValue; @@ -26,20 +29,21 @@ import org.springframework.stereotype.Component; import java.time.Clock; -import java.time.Duration; import java.time.Instant; import java.util.AbstractMap; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.NavigableMap; +import java.util.Objects; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.Future; import java.util.function.Supplier; import java.util.stream.Collectors; +import static cz.cvut.kbss.termit.util.Constants.THROTTLE_THRESHOLD; import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_SINGLETON; /** @@ -51,15 +55,19 @@ @Profile("!test") public class ThrottleAspect { - public static final Duration THROTTLE_THRESHOLD = Duration.ofSeconds(10); // TODO: config value - private static final Logger LOG = LoggerFactory.getLogger(ThrottleAspect.class); - private final Map> throttledFutures; + /** + * group, identifier -> future + */ + private final Map> throttledFutures; - private final Map lastRun; + private final Map lastRun; - private final NavigableMap> scheduledFutures; + /** + * group, identifier -> future + */ + private final NavigableMap> scheduledFutures; /** * synchronize before access @@ -81,9 +89,10 @@ public ThrottleAspect(TaskScheduler taskScheduler) { clock = Clock.systemUTC(); // used by Instant.now() by default } - public ThrottleAspect(Map> throttledFutures, Map lastRun, - NavigableMap> scheduledFutures, TaskScheduler taskScheduler, - Clock clock) { + protected ThrottleAspect(Map> throttledFutures, + Map lastRun, + NavigableMap> scheduledFutures, TaskScheduler taskScheduler, + Clock clock) { this.throttledFutures = throttledFutures; this.lastRun = lastRun; this.scheduledFutures = scheduledFutures; @@ -114,17 +123,15 @@ private static EvaluationContext makeContext(JoinPoint joinPoint, Map ThrottledFuture transferTask(@NotNull ThrottledFuture source, - @NotNull ThrottledFuture target) { - return (ThrottledFuture) source.transfer(target); + @SuppressWarnings("unchecked") + private static ThrottledFuture transferTask(@NotNull ThrottledFuture source, + @NotNull ThrottledFuture target) { + // casting the type parameter to Object + return ((ThrottledFuture) source).transfer((ThrottledFuture) target); } private @NotNull AbstractMap.SimpleImmutableEntry> getFutureTask( - @NotNull ProceedingJoinPoint joinPoint, String key, @NotNull ThrottledFuture future) + @NotNull ProceedingJoinPoint joinPoint, Identifier identifier, @NotNull ThrottledFuture future) throws Throwable { final Supplier securityContext = SecurityContextHolder.getDeferredContext(); @@ -141,15 +148,20 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture // the future must contain the same type - ensured by accessing with the unique key if (isFuture) { - ThrottledFuture throttledMethodFuture = (ThrottledFuture) joinPoint.proceed(); - // future acquired by key or a new future supplied, ensuring the same type - // ThrottledFuture#updateOther will create a new future when required - throttledFuture = transferTask(throttledMethodFuture, future); + Object result = joinPoint.proceed(); + if (result instanceof ThrottledFuture throttledMethodFuture) { + // future acquired by key or a new future supplied, ensuring the same type + // ThrottledFuture#updateOther will create a new future when required + throttledFuture = transferTask(throttledMethodFuture, future); + } else { + throw new ThrottleAspectException("Returned value is not a ThrottledFuture"); + } } else { future.update(() -> { try { return joinPoint.proceed(); } catch (Throwable e) { + // exception happened inside throttled method throw new TermItException(e); } }); @@ -163,20 +175,20 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture // mark the thread as throttled final Long threadId = Thread.currentThread().getId(); throttledThreads.add(threadId); - LOG.trace("Running throttled task '{}'", key); + LOG.trace("Running throttled task '{}'", identifier); // restore the security context SecurityContextHolder.setContext(securityContext.get()); try { // update last run timestamp synchronized (lastRun) { - lastRun.put(key, Instant.now(clock)); + lastRun.put(identifier, Instant.now(clock)); } // fulfill the future future.run(); } finally { // clear the security context SecurityContextHolder.clearContext(); - LOG.trace("Throttled task run finished '{}'", key); + LOG.trace("Throttled task run finished '{}'", identifier); // remove throttled mark throttledThreads.remove(threadId); } @@ -192,13 +204,12 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture * @implNote Around advice configured in {@code spring-aop.xml} */ public synchronized @Nullable Object throttleMethodCall(@NotNull ProceedingJoinPoint joinPoint, - @NotNull Throttle throttleAnnotation) - throws Throwable { + @NotNull Throttle throttleAnnotation) throws Throwable { // if the current thread is already executing a throttled code, we want to skip further throttling if (throttledThreads.contains(Thread.currentThread().getId())) { // proceed with method execution - Object result = joinPoint.proceed(); + final Object result = joinPoint.proceed(); if (result instanceof ThrottledFuture throttledFuture) { // directly run throttled future throttledFuture.run(); @@ -210,83 +221,107 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // construct the throttle instance key - final String key = makeKey(joinPoint, throttleAnnotation); - LOG.trace("Throttling task with key '{}'", key); - - Map.Entry> ceiling = scheduledFutures.higherEntry(key); - if (!throttleAnnotation.group().isBlank() && ceiling != null) { - Future ceilingFuture = ceiling.getValue(); - if (!ceilingFuture.isDone() && !ceilingFuture.isCancelled()) { - LOG.trace("Throttling canceled due to scheduled ceiling task '{}'", ceiling.getKey()); - return ThrottledFuture.canceled(); + final Identifier identifier = makeIdentifier(joinPoint, throttleAnnotation); + LOG.trace("Throttling task with key '{}'", identifier); + + if (!throttleAnnotation.group().isBlank()) { + // check if there is a task with higher group + // and if so, cancel this task in favor of the higher group + final Map.Entry> lowerEntry = scheduledFutures.lowerEntry(identifier); + if (lowerEntry != null) { + final Future lowerFuture = lowerEntry.getValue(); + boolean hasGroupPrefix = identifier.hasGroupPrefix(lowerEntry.getKey().getGroup()); + if (hasGroupPrefix && !lowerFuture.isDone() && !lowerFuture.isCancelled()) { + LOG.trace("Throttling canceled due to scheduled lower task '{}'", lowerEntry.getKey()); + return ThrottledFuture.canceled(); + } } + + cancelWithLowerGroup(throttleAnnotation); } // if there is a scheduled task and this throttled instance was executed in the last THROTTLE_THRESHOLD // cancel the scheduled task // -> the execution is further delayed - Future oldFuture = scheduledFutures.get(key); - boolean throttleNotExpired = lastRun.getOrDefault(key, Instant.EPOCH) + Future oldFuture = scheduledFutures.get(identifier); + boolean throttleNotExpired = lastRun.getOrDefault(identifier, Instant.EPOCH) .isAfter(Instant.now(clock).minus(THROTTLE_THRESHOLD)); if (oldFuture != null && throttleNotExpired) { oldFuture.cancel(false); } // acquire a throttled future from a map, or make a new one - ThrottledFuture oldThrottledFuture = throttledFutures.getOrDefault(key, new ThrottledFuture<>()); + ThrottledFuture oldThrottledFuture = throttledFutures.getOrDefault(identifier, new ThrottledFuture<>()); - AbstractMap.SimpleImmutableEntry> entry = getFutureTask(joinPoint, key, oldThrottledFuture); + AbstractMap.SimpleImmutableEntry> entry = getFutureTask(joinPoint, identifier, oldThrottledFuture); Runnable task = entry.getKey(); ThrottledFuture future = entry.getValue(); // update the throttled future in the map - throttledFutures.put(key, future); - - Object result = voidOrFuture(signature, key, future); + throttledFutures.put(identifier, future); - clearGroup(throttleAnnotation); + Object result = resultVoidOrFuture(signature, future); if (oldFuture == null || oldFuture.isDone() || oldFuture.isCancelled()) { - Future scheduled = (Future) taskScheduler.schedule(task, Instant.now(clock) - .plus(THROTTLE_THRESHOLD)); - scheduledFutures.put(key, scheduled); + schedule(identifier, task); } return result; } - private void clearGroup(Throttle throttleAnnotation) { - if (!throttleAnnotation.clearGroup().isBlank()) { - Map> toClear = scheduledFutures.tailMap(throttleAnnotation.clearGroup()); - toClear.forEach((k, f) -> f.cancel(false)); - toClear.clear(); + @SuppressWarnings("unchecked") + private void schedule(Identifier identifier, Runnable task) { + Future scheduled = taskScheduler.schedule(task, Instant.now(clock).plus(THROTTLE_THRESHOLD)); + // casting the type parameter to Object + scheduledFutures.put(identifier, (Future) scheduled); + } + + private void cancelWithLowerGroup(Throttle throttleAnnotation) { + // look for any futures with lower group + // cancel them and remove from maps + Future higherFuture; + Identifier higherKey = scheduledFutures.higherKey(new Identifier(throttleAnnotation.group(), "")); + while (higherKey != null) { + if (!higherKey.hasGroupPrefix(throttleAnnotation.group()) || higherKey.getGroup() + .equals(throttleAnnotation.group())) { + break; + } + + higherFuture = scheduledFutures.get(higherKey); + higherFuture.cancel(false); + final ThrottledFuture throttledFuture = throttledFutures.get(higherKey); + if (throttledFuture != null) { + throttledFuture.cancel(false); + } + + scheduledFutures.remove(higherKey); + throttledFutures.remove(higherKey); + + higherKey = scheduledFutures.higherKey(higherKey); } } - private String makeKey(JoinPoint joinPoint, Throttle throttleAnnotation) throws IllegalCallerException { + private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotation) throws IllegalCallerException { final String identifier = constructIdentifier(joinPoint, throttleAnnotation.value()); final String groupIdentifier = throttleAnnotation.group(); - if (identifier == null) { - throw new IllegalCallerException("Identifier in Debounce annotation resolved to null"); - } - - return groupIdentifier + "-" + joinPoint.getSignature().toShortString() + "-" + identifier; + return new Identifier(groupIdentifier, joinPoint.getSignature().toShortString() + "-" + identifier); } - private @Nullable Object voidOrFuture(@NotNull MethodSignature signature, String key, - ThrottledFuture future) + private @Nullable Object resultVoidOrFuture(@NotNull MethodSignature signature, ThrottledFuture future) throws IllegalCallerException { Class returnType = signature.getReturnType(); - if (returnType.isAssignableFrom(Future.class)) { + if (returnType.isAssignableFrom(ThrottledFuture.class)) { return future; } if (Void.TYPE.equals(returnType) || Void.class.equals(returnType)) { return null; } - throw new IllegalCallerException("Invalid return type for " + signature + " annotated with @Debounce, only Future or void allowed!"); + throw new ThrottleAspectException("Invalid return type for " + signature + " annotated with @Debounce, only Future or void allowed!"); } - private @Nullable String constructIdentifier(JoinPoint joinPoint, String expression) { + + @SuppressWarnings({"unchecked"}) + private @NotNull String constructIdentifier(JoinPoint joinPoint, String expression) throws ThrottleAspectException { if (expression == null || expression.isBlank()) { return ""; } @@ -297,10 +332,23 @@ private String makeKey(JoinPoint joinPoint, Throttle throttleAnnotation) throws final EvaluationContext context = makeContext(joinPoint, parameters); - final List identifier = parser.parseExpression(expression).getValue(context, List.class); - assert identifier != null; + final Expression identifierExp = parser.parseExpression(expression); + try { + Object result = identifierExp.getValue(context); + + if (result instanceof String stringResult) { + return stringResult; + } + + // casting the expression result to the list of objects + // exception handled and rethrown by try-catch + Collection identifierList = (Collection) identifierExp.getValue(context); + Objects.requireNonNull(identifierList); + return identifierList.stream().map(Object::toString).collect(Collectors.joining("-")); + } catch (EvaluationException | ClassCastException | NullPointerException e) { + throw new ThrottleAspectException("The identifier expression: '" + expression + "' has not been resolved to a Collection", e); + } - return identifier.stream().map(Object::toString).collect(Collectors.joining("-")); } /** @@ -337,4 +385,36 @@ public void write(@NotNull EvaluationContext context, Object target, @NotNull St throw new AccessException("Unsupported operation"); } } + + /** + * A composed identifier of a throttled instance. + *
    
    +     *     String group
    +     *     String identifier
    +     * 
    + * Implements comparable, first comparing group, then identifier. + */ + protected static class Identifier extends ComparablePair { + + public Identifier(String group, String identifier) { + super(group, identifier); + } + + public String getGroup() { + return this.getFirst(); + } + + public String getIdentifier() { + return this.getSecond(); + } + + public boolean hasGroupPrefix(String group) { + return this.getGroup().indexOf(group) == 0; + } + + @Override + public String toString() { + return "ThrottleAspect.Identifier{group='" + getGroup() + "',identifier='" + getIdentifier() + "'}"; + } + } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java index e03aaebcf..769764ff2 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java @@ -12,14 +12,14 @@ public class MockedMethodSignature implements MethodSignature { private String[] parameterNames; - public MockedMethodSignature(Class returnType, Class[] parameterTypes, String[] parameterNames) { + public MockedMethodSignature(Class returnType, Class[] parameterTypes, String[] parameterNames) { this.returnType = returnType; this.parameterTypes = parameterTypes; this.parameterNames = parameterNames; } @Override - public Class getReturnType() { + public Class getReturnType() { return returnType; } @@ -64,7 +64,7 @@ public int getModifiers() { } @Override - public Class getDeclaringType() { + public Class getDeclaringType() { return null; } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java index 59b1c84c9..951f56081 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java @@ -10,12 +10,9 @@ public class MockedThrottle implements Throttle { private String group; - private String clearGroup; - - public MockedThrottle(String value, String group, String clearGroup) { + public MockedThrottle(String value, String group) { this.value = value; this.group = group; - this.clearGroup = clearGroup; } @Override @@ -28,11 +25,6 @@ public MockedThrottle(String value, String group, String clearGroup) { return group; } - @Override - public @NotNull String clearGroup() { - return clearGroup; - } - @Override public Class annotationType() { return Throttle.class; @@ -45,8 +37,4 @@ public void setValue(String value) { public void setGroup(String group) { this.group = group; } - - public void setClearGroup(String clearGroup) { - this.clearGroup = clearGroup; - } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index 75c1df176..a18cdac64 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -1,9 +1,12 @@ package cz.cvut.kbss.termit.util.throttle; import com.vladsch.flexmark.util.collection.OrderedMap; +import cz.cvut.kbss.termit.exception.ThrottleAspectException; import org.aspectj.lang.ProceedingJoinPoint; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.scheduling.TaskScheduler; import java.time.Clock; @@ -14,16 +17,22 @@ import java.util.AbstractMap; import java.util.Map; import java.util.NavigableMap; +import java.util.Optional; import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; import java.util.function.Supplier; -import static cz.cvut.kbss.termit.util.throttle.ThrottleAspect.THROTTLE_THRESHOLD; +import static cz.cvut.kbss.termit.util.Constants.THROTTLE_THRESHOLD; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; @@ -34,11 +43,11 @@ class ThrottleAspectTest { - OrderedMap> throttledFutures; + OrderedMap> throttledFutures; - OrderedMap lastRun; + OrderedMap lastRun; - NavigableMap> scheduledFutures; + NavigableMap> scheduledFutures; TaskScheduler taskScheduler; @@ -46,18 +55,24 @@ class ThrottleAspectTest { ThrottleAspect sut; - Throttle throttleA; + MockedThrottle throttleA; - Throttle throttleB; + MockedThrottle throttleB; + + MockedThrottle throttleC; MockedMethodSignature signatureA; MockedMethodSignature signatureB; + MockedMethodSignature signatureC; + ProceedingJoinPoint joinPointA; ProceedingJoinPoint joinPointB; + ProceedingJoinPoint joinPointC; + Clock clock = Clock.fixed(Instant.now(), ZoneId.of("UTC")); void mockA() throws Throwable { @@ -69,7 +84,7 @@ void mockA() throws Throwable { when(joinPointA.getArgs()).thenReturn(new Object[]{new Object(), new Object()}); when(joinPointA.getTarget()).thenReturn(this); - throttleA = new MockedThrottle("'string literal'", "my.testing", ""); + throttleA = new MockedThrottle("'string literal'", "my.testing.group.A"); } void mockB() throws Throwable { @@ -82,13 +97,26 @@ void mockB() throws Throwable { when(joinPointB.getTarget()).thenReturn(this); - throttleB = new MockedThrottle("{param.get('second'), param.get('first')}", "my.testing.group", ""); + throttleB = new MockedThrottle("{paramName.get('second'), paramName.get('first')}", "my.testing.group.B"); + } + + void mockC() throws Throwable { + joinPointC = mock(ProceedingJoinPoint.class); + when(joinPointC.proceed()).thenReturn(null); + signatureC = spy(new MockedMethodSignature(Void.TYPE, new Class[]{Object.class, Object.class}, new String[]{ + "paramA", "paramB"})); + when(joinPointC.getSignature()).thenReturn(signatureC); + when(joinPointC.getArgs()).thenReturn(new Object[]{new Object(), new Object()}); + when(joinPointC.getTarget()).thenReturn(this); + + throttleC = new MockedThrottle("'string literal'", "my.testing"); } @BeforeEach void beforeEach() throws Throwable { mockA(); mockB(); + mockC(); taskSchedulerTasks = new OrderedMap<>(); @@ -133,14 +161,14 @@ void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { doAnswer(invocation -> new ThrottledFuture().update(anotherMethodResult)).when(joinPointA).proceed(); final Instant firstCall = getInstant(); - // simulate first call with params 0 & 1 + // simulate first call when(joinPointA.getArgs()).thenReturn(new Object[]{params[0], params[1]}); - final Object result1 = sut.throttleMethodCall(joinPointA, throttleA); + sut.throttleMethodCall(joinPointA, throttleA); addSecond(); // simulate second call when(joinPointA.getArgs()).thenReturn(new Object[]{params[2], params[3]}); - final Object result2 = sut.throttleMethodCall(joinPointA, throttleA); + sut.throttleMethodCall(joinPointA, throttleA); addSecond(); // change the return value of the method to the prepared future @@ -148,13 +176,7 @@ void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { // simulate last call when(joinPointA.getArgs()).thenReturn(new Object[]{params[4], params[5]}); - final Object result3 = sut.throttleMethodCall(joinPointA, throttleA); - - // all three calls returned the same future - // this ensures that calls are actually batched and single result - // satisfies all batched calls - assertEquals(result1, result2); - assertEquals(result1, result3); + sut.throttleMethodCall(joinPointA, throttleA); // there should be only a single scheduled future assertEquals(1, scheduledFutures.size()); @@ -178,12 +200,37 @@ void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { assertEquals(methodTask.get(), future.get()); } + @Test + void callsInThrottleIntervalAreMerged() throws Throwable { + final String[] params = new String[]{"param1", "param2", "param3", "param4", "param5", "param6"}; + // define a future as the return type of the method + doReturn(Future.class).when(signatureA).getReturnType(); + + // for each method call, make new future with "another method task" + doAnswer(invocation -> new ThrottledFuture()).when(joinPointA).proceed(); + + // simulate first call + when(joinPointA.getArgs()).thenReturn(new Object[]{params[0], params[1]}); + final Object result1 = sut.throttleMethodCall(joinPointA, throttleA); + + addSecond(); + // simulate second call + when(joinPointA.getArgs()).thenReturn(new Object[]{params[2], params[3]}); + final Object result2 = sut.throttleMethodCall(joinPointA, throttleA); + + // both calls returned the same future + // this ensures that calls are actually merged and a single result satisfies all merged calls + assertInstanceOf(Future.class, result1); + assertInstanceOf(Future.class, result2); + assertEquals(result1, result2); + } + @Test void schedulesNewFutureWhenTheOldOneIsCompleted() throws Throwable { sut.throttleMethodCall(joinPointA, throttleA); addSecond(); - final AbstractMap.SimpleImmutableEntry> firstEntry = new AbstractMap.SimpleImmutableEntry<>(scheduledFutures.firstEntry()); + final AbstractMap.SimpleImmutableEntry> firstEntry = new AbstractMap.SimpleImmutableEntry<>(scheduledFutures.firstEntry()); final Runnable firstTask = taskSchedulerTasks.getKey(0); assertNotNull(firstTask); firstTask.run(); @@ -193,11 +240,135 @@ void schedulesNewFutureWhenTheOldOneIsCompleted() throws Throwable { assertEquals(1, scheduledFutures.size()); assertEquals(2, taskSchedulerTasks.size()); + + assertTrue(firstEntry.getValue().isDone()); + final Future secondFuture = scheduledFutures.get(firstEntry.getKey()); assertNotEquals(firstEntry.getValue(), secondFuture); - assertTrue(firstEntry.getValue().isDone()); + assertFalse(secondFuture.isDone()); assertFalse(secondFuture.isCancelled()); } + @Test + void cancelsAllScheduledFuturesWhenNewTaskWithLowerGroupIsScheduled() throws Throwable { + throttleA.setGroup("the.group.identifier.first"); + throttleB.setGroup("the.group.identifier.second"); + throttleC.setGroup("the.group.identifier"); + + sut.throttleMethodCall(joinPointA, throttleA); + sut.throttleMethodCall(joinPointB, throttleB); + + final Map> futures = Map.copyOf(scheduledFutures); + + assertEquals(2, throttledFutures.size()); + assertEquals(2, scheduledFutures.size()); + assertEquals(2, taskSchedulerTasks.size()); + + sut.throttleMethodCall(joinPointC, throttleC); + + assertEquals(1, throttledFutures.size()); + assertEquals(1, scheduledFutures.size()); + assertEquals(3, taskSchedulerTasks.size()); + + assertEquals(2, futures.size()); + futures.forEach((k, f) -> assertTrue(f.isCancelled())); + } + + @Test + void immediatelyCancelsNewFutureWhenLowerGroupIsAlreadyScheduled() throws Throwable { + throttleA.setGroup("the.group.identifier"); + throttleB.setGroup("the.group.identifier.with.higher.value"); + + signatureB.setReturnType(Future.class); + when(joinPointB.proceed()).then(invocation -> new ThrottledFuture<>()); + + sut.throttleMethodCall(joinPointA, throttleA); + + final Map> futures = Map.copyOf(scheduledFutures); + + Object result = sut.throttleMethodCall(joinPointB, throttleB); + assertNotNull(result); + assertInstanceOf(ThrottledFuture.class, result); + Future secondCall = (Future) result; + + assertEquals(1, scheduledFutures.size()); + + final Future oldFuture = futures.values().iterator().next(); + final Future currentFuture = scheduledFutures.values().iterator().next(); + assertEquals(oldFuture, currentFuture); + assertFalse(currentFuture.isDone()); + assertFalse(currentFuture.isCancelled()); + + assertTrue(secondCall.isCancelled()); + } + + @Test + void aspectDoesNotThrowWhenMethodReturnsUnboxedVoidBySignature() throws Throwable { + signatureA.setReturnType(Void.TYPE); + when(joinPointA.proceed()).thenReturn(null); + + assertDoesNotThrow(() -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @Test + void aspectDoesNotThrowWhenMethodReturnsBoxedVoidBySignature() throws Throwable { + signatureA.setReturnType(Void.class); + when(joinPointA.proceed()).thenReturn(null); + + assertDoesNotThrow(() -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @Test + void aspectDoesNotThrowWhenMethodReturnsFutureBySignature() throws Throwable { + signatureA.setReturnType(Future.class); + when(joinPointA.proceed()).then(invocation -> new ThrottledFuture<>()); + + assertDoesNotThrow(() -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @Test + void aspectDoesNotThrowWhenMethodReturnsThrottledFutureBySignature() throws Throwable { + signatureA.setReturnType(ThrottledFuture.class); + when(joinPointA.proceed()).then(invocation -> new ThrottledFuture<>()); + + assertDoesNotThrow(() -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @ParameterizedTest + // just few sample classes + @ValueSource(classes = {String.class, Integer.class, Optional.class, FutureTask.class, CompletableFuture.class}) + void aspectThrowsWhenMethodNotReturnsVoidOrFutureBySignature(Class returnType) throws Throwable { + signatureA.setReturnType(returnType); + when(joinPointA.proceed()).thenReturn(new Object()); + + assertThrows(ThrottleAspectException.class, () -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @Test + void aspectDoesThrowWhenMethodReturnsNullByValueAndFutureBySignature() throws Throwable { + signatureA.setReturnType(Future.class); + when(joinPointA.proceed()).thenReturn(null); + + assertThrows(ThrottleAspectException.class, () -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @ParameterizedTest + @ValueSource(classes = {Future.class, ThrottledFuture.class}) + void aspectThrowsWhenMethodDoesNotReturnsThrottledFutureObject(Class returnType) throws Throwable { + signatureA.setReturnType(returnType); + when(joinPointA.proceed()).thenReturn(new Object()); + + assertThrows(ThrottleAspectException.class, () -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @ParameterizedTest + @ValueSource(classes = {Future.class, ThrottledFuture.class}) + void aspectThrowsWhenMethodReturnsNullWithFutureBySignature(Class returnType) throws Throwable { + signatureA.setReturnType(returnType); + when(joinPointA.proceed()).thenReturn(null); + + assertThrows(ThrottleAspectException.class, () -> sut.throttleMethodCall(joinPointA, throttleA)); + } + } From 94a47637786ab60cd4397138718a6a298574aeab Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 21 Aug 2024 13:03:38 +0200 Subject: [PATCH 081/150] support for SpEL in groups --- .../service/business/VocabularyService.java | 8 +-- .../service/document/AnnotationGenerator.java | 2 +- .../kbss/termit/util/throttle/Throttle.java | 16 +++-- .../termit/util/throttle/ThrottleAspect.java | 64 +++++++++++++------ .../util/throttle/ThrottleGroupProvider.java | 20 ++++++ .../util/throttle/ThrottleAspectTest.java | 29 ++++++--- 6 files changed, 98 insertions(+), 41 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index bb55cee76..d3f09d045 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -294,8 +294,8 @@ public List getChangesOfContent(Vocabulary vocabulary) { * @param vocabulary Vocabulary to be analyzed */ @Transactional - @Throttle(value = "{vocabulary.getUri()}", - group = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY_TERMS_ALL_DEFINITIONS) + @Throttle(value = "{#vocabulary.getUri()}", + group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyAllTerms(#vocabulary.getUri())") @PreAuthorize("@vocabularyAuthorizationService.canModify(#vocabulary)") public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { vocabulary = findRequired(vocabulary.getUri()); // required when throttling @@ -312,7 +312,7 @@ public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { /** * Runs text analysis on definitions of all terms in all vocabularies. */ - @Throttle(group = Constants.DebouncingGroups.TEXT_ANALYSIS_VOCABULARY) + @Throttle(group = "T(ThrottleGroupProvider).getTextAnalysisVocabulariesAll()") @Transactional public void runTextAnalysisOnAllVocabularies() { LOG.debug("Analyzing definitions of all terms in all vocabularies."); @@ -347,7 +347,7 @@ public void remove(Vocabulary asset) { * * @param vocabulary Vocabulary to validate */ - @Throttle("{vocabulary}") + @Throttle("{#vocabulary}") public Future> validateContents(URI vocabulary) { return ThrottledFuture.of(() -> repositoryService.validateContents(vocabulary)); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index e196a14d4..ca8b7bce4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -96,7 +96,7 @@ private void saveAnnotatedContent(File file, InputStream input) { * @param annotatedTerm Term whose definition was annotated */ @Transactional - @Throttle(value = "{annotatedTerm.getUri()}") + @Throttle(value = "{#annotatedTerm.getUri()}") public void generateAnnotations(InputStream content, AbstractTerm annotatedTerm) { // We assume the content (text analysis output) is HTML-compatible final TermOccurrenceResolver occurrenceResolver = resolvers.htmlTermOccurrenceResolver(); diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java index 6179bf404..dbced75bc 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java @@ -10,9 +10,9 @@ /** * Indicates that calls to this method will be throttled & debounced. * Meaning that the action will be executed on the first call of the method, - * then every next call which comes earlier then {@link ThrottleAspect#THROTTLE_THRESHOLD} + * then every next call which comes earlier then {@link cz.cvut.kbss.termit.util.Constants#THROTTLE_THRESHOLD} * will return a pending future which might be resolved by a newer future. - * Futures will be resolved once per {@link ThrottleAspect#THROTTLE_THRESHOLD} (+ duration to execute the future task). + * Futures will be resolved once per {@link cz.cvut.kbss.termit.util.Constants#THROTTLE_THRESHOLD} (+ duration to execute the future task). *

    * Every annotated method should be tested for throttling to ensure it has the desired effect. *

    @@ -23,17 +23,17 @@ * or another type assignable from {@link ThrottledFuture}, * but the returned concrete object has to be {@link ThrottledFuture}, method call will throw otherwise! *

    - * Note that returned future can be canceled (see {@link #clearGroup()}) + * Note that returned future can be canceled *

    * Example implementation: *

    
    - *  {@code @}Throttle(value = "{paramObj, anotherParam}")
    + *  {@code @}Throttle(value = "{#paramObj, #anotherParam}")
      *  public Future<String> myFunction(Object paramObj, Object anotherParam) {
      *      return ThrottledFuture.of(() -> doStuff());
      *  }
      * 
    * - * @implNote The annotation is being processed by {@link ThrottleAspect#throttledThreads} + * @implNote Methods will be called from a separated thread. * @see Debouncing and Throttling * @see Throttling + debouncing image */ @@ -42,7 +42,8 @@ public @interface Throttle { /** - * The Spring-EL expression returning a List of Objects which will be used to construct the unique identifier + * The Spring-EL expression + * returning a List of Objects or a String which will be used to construct the unique identifier * for this throttled instance. *

    * In the expression, you have available method parameters. @@ -50,7 +51,8 @@ @NotNull String value() default ""; /** - * The Spring-EL expression returning group identifier (String) to which this throttle belongs. + * The Spring-EL expression + * returning group identifier a List of Objects or a String to which this throttle belongs. *

    * When there is a pending task P with a group * that is also a prefix for a group of a new task N, diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index b306e0620..eedfd762e 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -1,5 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; +import cz.cvut.kbss.termit.TermItApplication; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.ThrottleAspectException; import org.aspectj.lang.JoinPoint; @@ -21,8 +22,9 @@ import org.springframework.expression.PropertyAccessor; import org.springframework.expression.TypedValue; import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.expression.spel.support.DataBindingMethodResolver; -import org.springframework.expression.spel.support.SimpleEvaluationContext; +import org.springframework.expression.spel.support.DataBindingPropertyAccessor; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.expression.spel.support.StandardTypeLocator; import org.springframework.scheduling.TaskScheduler; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -31,6 +33,7 @@ import java.time.Clock; import java.time.Instant; import java.util.AbstractMap; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; @@ -78,6 +81,8 @@ public class ThrottleAspect { private final TaskScheduler taskScheduler; + private final StandardEvaluationContext standardEvaluationContext; + private final Clock clock; @Autowired @@ -87,6 +92,7 @@ public ThrottleAspect(TaskScheduler taskScheduler) { lastRun = new HashMap<>(); scheduledFutures = new TreeMap<>(); clock = Clock.systemUTC(); // used by Instant.now() by default + standardEvaluationContext = makeDefaultContext(); } protected ThrottleAspect(Map> throttledFutures, @@ -98,6 +104,22 @@ protected ThrottleAspect(Map> throttledFutur this.scheduledFutures = scheduledFutures; this.taskScheduler = taskScheduler; this.clock = clock; + standardEvaluationContext = makeDefaultContext(); + } + + private static StandardEvaluationContext makeDefaultContext() { + StandardEvaluationContext standardEvaluationContext = new StandardEvaluationContext(); + standardEvaluationContext.addPropertyAccessor(DataBindingPropertyAccessor.forReadOnlyAccess()); + + final ClassLoader loader = ThrottleAspect.class.getClassLoader(); + final StandardTypeLocator typeLocator = new StandardTypeLocator(loader); + + final String basePackage = TermItApplication.class.getPackageName(); + Arrays.stream(loader.getDefinedPackages()).map(Package::getName) + .filter(s -> s.indexOf(basePackage) == 0).forEach(typeLocator::registerImport); + + standardEvaluationContext.setTypeLocator(typeLocator); + return standardEvaluationContext; } /** @@ -116,11 +138,12 @@ private static void resolveParameters(Map map, MethodSignature s } } - private static EvaluationContext makeContext(JoinPoint joinPoint, Map parameters) { - return SimpleEvaluationContext.forPropertyAccessors(new MapPropertyAccessor<>(joinPoint.getTarget() - .getClass(), parameters)) - .withMethodResolvers(DataBindingMethodResolver.forInstanceMethodInvocation()) - .withRootObject(joinPoint.getTarget()).build(); + private EvaluationContext makeContext(JoinPoint joinPoint, Map parameters) { + StandardEvaluationContext context = new StandardEvaluationContext(); + standardEvaluationContext.applyDelegatesTo(context); + context.setRootObject(joinPoint.getTarget()); + context.setVariables(parameters); + return context; } @SuppressWarnings("unchecked") @@ -224,9 +247,9 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture final Identifier identifier = makeIdentifier(joinPoint, throttleAnnotation); LOG.trace("Throttling task with key '{}'", identifier); - if (!throttleAnnotation.group().isBlank()) { - // check if there is a task with higher group - // and if so, cancel this task in favor of the higher group + if (!identifier.getGroup().isBlank()) { + // check if there is a task with lower group + // and if so, cancel this task in favor of the lower group final Map.Entry> lowerEntry = scheduledFutures.lowerEntry(identifier); if (lowerEntry != null) { final Future lowerFuture = lowerEntry.getValue(); @@ -237,7 +260,7 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture } } - cancelWithLowerGroup(throttleAnnotation); + cancelWithHigherGroup(identifier); } // if there is a scheduled task and this throttled instance was executed in the last THROTTLE_THRESHOLD @@ -275,14 +298,17 @@ private void schedule(Identifier identifier, Runnable task) { scheduledFutures.put(identifier, (Future) scheduled); } - private void cancelWithLowerGroup(Throttle throttleAnnotation) { - // look for any futures with lower group + private void cancelWithHigherGroup(Identifier throttleAnnotation) { + if (throttleAnnotation.getGroup().isBlank()) { + return; + } + // look for any futures with higher group // cancel them and remove from maps Future higherFuture; - Identifier higherKey = scheduledFutures.higherKey(new Identifier(throttleAnnotation.group(), "")); + Identifier higherKey = scheduledFutures.higherKey(new Identifier(throttleAnnotation.getGroup(), "")); while (higherKey != null) { - if (!higherKey.hasGroupPrefix(throttleAnnotation.group()) || higherKey.getGroup() - .equals(throttleAnnotation.group())) { + if (!higherKey.hasGroupPrefix(throttleAnnotation.getGroup()) || higherKey.getGroup() + .equals(throttleAnnotation.getGroup())) { break; } @@ -302,7 +328,7 @@ private void cancelWithLowerGroup(Throttle throttleAnnotation) { private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotation) throws IllegalCallerException { final String identifier = constructIdentifier(joinPoint, throttleAnnotation.value()); - final String groupIdentifier = throttleAnnotation.group(); + final String groupIdentifier = constructIdentifier(joinPoint, throttleAnnotation.group()); return new Identifier(groupIdentifier, joinPoint.getSignature().toShortString() + "-" + identifier); } @@ -328,7 +354,6 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati final Map parameters = new HashMap<>(); resolveParameters(parameters, (MethodSignature) joinPoint.getSignature(), joinPoint); - assert !parameters.isEmpty(); final EvaluationContext context = makeContext(joinPoint, parameters); @@ -346,9 +371,8 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati Objects.requireNonNull(identifierList); return identifierList.stream().map(Object::toString).collect(Collectors.joining("-")); } catch (EvaluationException | ClassCastException | NullPointerException e) { - throw new ThrottleAspectException("The identifier expression: '" + expression + "' has not been resolved to a Collection", e); + throw new ThrottleAspectException("The expression: '" + expression + "' has not been resolved to a Collection or String", e); } - } /** diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java new file mode 100644 index 000000000..a72885098 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java @@ -0,0 +1,20 @@ +package cz.cvut.kbss.termit.util.throttle; + +import java.net.URI; + +public class ThrottleGroupProvider { + + private ThrottleGroupProvider() { + throw new AssertionError(); + } + + private static final String TEXT_ANALYSIS_VOCABULARIES = "TEXT_ANALYSIS_VOCABULARIES"; + + public static String getTextAnalysisVocabulariesAll() { + return TEXT_ANALYSIS_VOCABULARIES; + } + + public static String getTextAnalysisVocabularyAllTerms(URI vocabulary) { + return TEXT_ANALYSIS_VOCABULARIES + "_" + vocabulary; + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index a18cdac64..8b7ca0160 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -84,7 +84,7 @@ void mockA() throws Throwable { when(joinPointA.getArgs()).thenReturn(new Object[]{new Object(), new Object()}); when(joinPointA.getTarget()).thenReturn(this); - throttleA = new MockedThrottle("'string literal'", "my.testing.group.A"); + throttleA = new MockedThrottle("'string literal'", "'my.testing.group.A'"); } void mockB() throws Throwable { @@ -97,7 +97,7 @@ void mockB() throws Throwable { when(joinPointB.getTarget()).thenReturn(this); - throttleB = new MockedThrottle("{paramName.get('second'), paramName.get('first')}", "my.testing.group.B"); + throttleB = new MockedThrottle("{#paramName.get('second'), #paramName.get('first')}", "'my.testing.group.B'"); } void mockC() throws Throwable { @@ -109,7 +109,7 @@ void mockC() throws Throwable { when(joinPointC.getArgs()).thenReturn(new Object[]{new Object(), new Object()}); when(joinPointC.getTarget()).thenReturn(this); - throttleC = new MockedThrottle("'string literal'", "my.testing"); + throttleC = new MockedThrottle("'string literal'", "'my.testing'"); } @BeforeEach @@ -252,9 +252,9 @@ void schedulesNewFutureWhenTheOldOneIsCompleted() throws Throwable { @Test void cancelsAllScheduledFuturesWhenNewTaskWithLowerGroupIsScheduled() throws Throwable { - throttleA.setGroup("the.group.identifier.first"); - throttleB.setGroup("the.group.identifier.second"); - throttleC.setGroup("the.group.identifier"); + throttleA.setGroup("'the.group.identifier.first'"); + throttleB.setGroup("'the.group.identifier.second'"); + throttleC.setGroup("'the.group.identifier'"); sut.throttleMethodCall(joinPointA, throttleA); sut.throttleMethodCall(joinPointB, throttleB); @@ -277,8 +277,8 @@ void cancelsAllScheduledFuturesWhenNewTaskWithLowerGroupIsScheduled() throws Thr @Test void immediatelyCancelsNewFutureWhenLowerGroupIsAlreadyScheduled() throws Throwable { - throttleA.setGroup("the.group.identifier"); - throttleB.setGroup("the.group.identifier.with.higher.value"); + throttleA.setGroup("'the.group.identifier'"); + throttleB.setGroup("'the.group.identifier.with.higher.value'"); signatureB.setReturnType(Future.class); when(joinPointB.proceed()).then(invocation -> new ThrottledFuture<>()); @@ -357,7 +357,7 @@ void aspectDoesThrowWhenMethodReturnsNullByValueAndFutureBySignature() throws Th @ValueSource(classes = {Future.class, ThrottledFuture.class}) void aspectThrowsWhenMethodDoesNotReturnsThrottledFutureObject(Class returnType) throws Throwable { signatureA.setReturnType(returnType); - when(joinPointA.proceed()).thenReturn(new Object()); + when(joinPointA.proceed()).thenReturn(new FutureTask<>(()->"")); assertThrows(ThrottleAspectException.class, () -> sut.throttleMethodCall(joinPointA, throttleA)); } @@ -371,4 +371,15 @@ void aspectThrowsWhenMethodReturnsNullWithFutureBySignature(Class returnType) assertThrows(ThrottleAspectException.class, () -> sut.throttleMethodCall(joinPointA, throttleA)); } + @Test + void aspectResolvesThrottleGroupProviderClassInSpEL() throws Throwable { + throttleA.setGroup("T(ThrottleGroupProvider).getTextAnalysisVocabulariesAll()"); + + sut.throttleMethodCall(joinPointA, throttleA); + + final String expectedGroup = ThrottleGroupProvider.getTextAnalysisVocabulariesAll(); + final String resolvedGroup = scheduledFutures.firstEntry().getKey().getGroup(); + assertEquals(expectedGroup, resolvedGroup); + } + } From 3b59545ebd0fd93c8f1a671feb53530262960cf7 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 22 Aug 2024 08:52:27 +0200 Subject: [PATCH 082/150] explicitly handle transactions --- .../dao/util/ScheduledContextRemover.java | 65 +++++++++ .../termit/rest/VocabularyController.java | 7 + .../rest/handler/RestExceptionHandler.java | 6 + .../cvut/kbss/termit/security/JwtUtils.java | 6 +- .../service/document/AnnotationGenerator.java | 10 +- .../service/document/TextAnalysisService.java | 4 +- .../termit/util/throttle/ComparablePair.java | 53 -------- .../cvut/kbss/termit/util/throttle/Pair.java | 61 +++++++++ .../termit/util/throttle/ThrottleAspect.java | 125 ++++++++---------- .../util/throttle/ThrottleGroupProvider.java | 1 + .../util/throttle/TransactionExecutor.java | 22 +++ src/main/resources/spring-aop.xml | 2 +- .../document/AnnotationGeneratorTest.java | 8 +- .../document/TextAnalysisServiceTest.java | 6 +- .../util/throttle/ThrottleAspectBeanTest.java | 67 ++++++++++ .../util/throttle/ThrottleAspectTest.java | 74 ++++++++--- .../ThrottleAspectTestContextConfig.java | 30 +++++ .../util/throttle/ThrottledService.java | 11 ++ 18 files changed, 404 insertions(+), 154 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java delete mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/ComparablePair.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/Pair.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/TransactionExecutor.java create mode 100644 src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java create mode 100644 src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java create mode 100644 src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledService.java diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java new file mode 100644 index 000000000..326fe0ab0 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java @@ -0,0 +1,65 @@ +package cz.cvut.kbss.termit.persistence.dao.util; + +import cz.cvut.kbss.jopa.model.EntityManager; +import cz.cvut.kbss.termit.util.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.NonNull; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URI; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Drops registered repository contexts at scheduled moments. + *

    + * This allows to move time-consuming removal of repository contexts containing a lot of data to times of low system + * activity. + */ +@Component +public class ScheduledContextRemover { + + private static final Logger LOG = LoggerFactory.getLogger(ScheduledContextRemover.class); + + private final EntityManager em; + + private final Set contextsToRemove = new HashSet<>(); + + public ScheduledContextRemover(EntityManager em) { + this.em = em; + } + + /** + * Schedules the specified context identifier for removal at the next execution of the context cleanup. + * + * @param contextUri Identifier of the context to remove + * @see #runContextRemoval() + */ + public synchronized void scheduleForRemoval(@NonNull URI contextUri) { + LOG.debug("Scheduling context {} for removal.", Utils.uriToString(contextUri)); + contextsToRemove.add(Objects.requireNonNull(contextUri)); + } + + /** + * Runs the removal of the registered repository contexts. + *

    + * This method is scheduled and should not be invoked manually. + * + * @see #scheduleForRemoval(URI) + */ + @Transactional + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) + public synchronized void runContextRemoval() { + LOG.trace("Running scheduled repository context removal."); + contextsToRemove.forEach(g -> { + LOG.trace("Dropping repository context {}.", Utils.uriToString(g)); + em.createNativeQuery("DROP GRAPH ?g").setParameter("g", g).executeUpdate(); + }); + contextsToRemove.clear(); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 5a3a6b6aa..cc7c763c6 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -49,6 +49,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.AsyncResult; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -61,6 +63,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.async.DeferredResult; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -69,7 +72,10 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; /** * Vocabulary management REST API. @@ -264,6 +270,7 @@ public List getHistory( return vocabularyService.getChanges(vocabulary); } + @Operation(security = {@SecurityRequirement(name = "bearer-key")}, description = "Gets summary info about changes made to the content of the vocabulary (term creation, editing).") @ApiResponses({ diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index d83ab552d..4056966f3 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -50,6 +50,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.multipart.MaxUploadSizeExceededException; /** @@ -279,4 +280,9 @@ public ResponseEntity invalidIdentifierException(HttpServletRequest r logException(e, request); return new ResponseEntity<>(errorInfo(request, e), HttpStatus.CONFLICT); } + + @ExceptionHandler + public void asyncRequestNotUsableException(HttpServletRequest request, AsyncRequestNotUsableException e) { + LOG.error("Client closed connection when processing request to {}", request.getRequestURI()); + } } diff --git a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java index 3d8b52c4c..99d8e52ae 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java @@ -27,6 +27,7 @@ import cz.cvut.kbss.termit.util.Utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; @@ -64,15 +65,16 @@ public class JwtUtils { private final ObjectMapper objectMapper; - private final Key key; - private final JwtParser jwtParser; + private final Key key; + @Autowired public JwtUtils(@Qualifier("objectMapper") ObjectMapper objectMapper, Configuration config) { this.objectMapper = objectMapper; this.key = Utils.isBlank(config.getJwt().getSecretKey()) ? Keys.secretKeyFor(SIGNATURE_ALGORITHM) : Keys.hmacShaKeyFor(config.getJwt().getSecretKey().getBytes(StandardCharsets.UTF_8)); + this.jwtParser = Jwts.parserBuilder().setSigningKey(key) .deserializeJsonWith(new JacksonDeserializer<>(objectMapper)) .build(); diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index ca8b7bce4..0407e6a41 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -48,13 +48,17 @@ public class AnnotationGenerator { private final TermOccurrenceSaver occurrenceSaver; + private final SynchronousTermOccurrenceSaver synchronousTermOccurrenceSaver; + @Autowired public AnnotationGenerator(DocumentManager documentManager, TermOccurrenceResolvers resolvers, - TermOccurrenceSaver occurrenceSaver) { + TermOccurrenceSaver occurrenceSaver, + SynchronousTermOccurrenceSaver synchronousTermOccurrenceSaver) { this.documentManager = documentManager; this.resolvers = resolvers; this.occurrenceSaver = occurrenceSaver; + this.synchronousTermOccurrenceSaver = synchronousTermOccurrenceSaver; } /** @@ -97,13 +101,13 @@ private void saveAnnotatedContent(File file, InputStream input) { */ @Transactional @Throttle(value = "{#annotatedTerm.getUri()}") - public void generateAnnotations(InputStream content, AbstractTerm annotatedTerm) { + public void generateAnnotationsSync(InputStream content, AbstractTerm annotatedTerm) { // We assume the content (text analysis output) is HTML-compatible final TermOccurrenceResolver occurrenceResolver = resolvers.htmlTermOccurrenceResolver(); LOG.debug("Resolving annotations of the definition of {}.", annotatedTerm); occurrenceResolver.parseContent(content, annotatedTerm); final List occurrences = occurrenceResolver.findTermOccurrences(); - occurrenceSaver.saveOccurrences(occurrences, annotatedTerm); + synchronousTermOccurrenceSaver.saveOccurrences(occurrences, annotatedTerm); LOG.trace("Finished generating annotations for the definition of {}.", annotatedTerm); } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java index dbc94dfaf..9966a874a 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java @@ -25,6 +25,7 @@ import cz.cvut.kbss.termit.persistence.dao.TextAnalysisRecordDao; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; +import cz.cvut.kbss.termit.util.throttle.Throttle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -80,6 +81,7 @@ public TextAnalysisService(RestTemplate restClient, Configuration config, Docume * @param file File whose content shall be analyzed * @param vocabularyContexts Identifiers of repository contexts containing vocabularies intended for text analysis */ + @Throttle("#file.getUri()") @Transactional public void analyzeFile(File file, Set vocabularyContexts) { Objects.requireNonNull(file); @@ -189,7 +191,7 @@ private void invokeTextAnalysisOnTerm(AbstractTerm term, TextAnalysisInput input return; } try (final InputStream is = result.get().getInputStream()) { - annotationGenerator.generateAnnotations(is, term); + annotationGenerator.generateAnnotationsSync(is, term); } } catch (WebServiceIntegrationException e) { throw e; diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ComparablePair.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ComparablePair.java deleted file mode 100644 index d02f14f6e..000000000 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ComparablePair.java +++ /dev/null @@ -1,53 +0,0 @@ -package cz.cvut.kbss.termit.util.throttle; - - -import org.jetbrains.annotations.NotNull; - -import java.util.Objects; - -/** - * First compares the first value, if they are equal, compares the second value. - */ -public class ComparablePair, V extends Comparable> - implements Comparable> { - - private final T first; - - private final V second; - - public ComparablePair(T first, V second) { - this.first = first; - this.second = second; - } - - public T getFirst() { - return first; - } - - public V getSecond() { - return second; - } - - @Override - public int compareTo(@NotNull ComparablePair o) { - final int firstComparison = this.first.compareTo(o.first); - if (firstComparison != 0) { - return firstComparison; - } - return this.second.compareTo(o.second); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ComparablePair that = (ComparablePair) o; - return Objects.equals(first, that.first) && Objects.equals(second, that.second); - } - - @Override - public int hashCode() { - return Objects.hash(first, second); - } -} - diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Pair.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Pair.java new file mode 100644 index 000000000..faaf7dace --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Pair.java @@ -0,0 +1,61 @@ +package cz.cvut.kbss.termit.util.throttle; + + +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public class Pair { + + private final T first; + + private final V second; + + public Pair(T first, V second) { + this.first = first; + this.second = second; + } + + public T getFirst() { + return first; + } + + public V getSecond() { + return second; + } + + + /** + * First compares the first value, if they are equal, compares the second value. + */ + public static class Comparable, V extends java.lang.Comparable> + extends Pair implements java.lang.Comparable> { + + public Comparable(T first, V second) { + super(first, second); + } + + @Override + public int compareTo(@NotNull Comparable o) { + final int firstComparison = this.getFirst().compareTo(o.getFirst()); + if (firstComparison != 0) { + return firstComparison; + } + return this.getSecond().compareTo(o.getSecond()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Comparable that = (Comparable) o; + return Objects.equals(getFirst(), that.getFirst()) && Objects.equals(getSecond(), that.getSecond()); + } + + @Override + public int hashCode() { + return Objects.hash(getFirst(), getSecond()); + } + } +} + diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index eedfd762e..e2fe41f9f 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -14,13 +14,10 @@ import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Scope; import org.springframework.core.annotation.Order; -import org.springframework.expression.AccessException; import org.springframework.expression.EvaluationContext; import org.springframework.expression.EvaluationException; import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; -import org.springframework.expression.PropertyAccessor; -import org.springframework.expression.TypedValue; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.DataBindingPropertyAccessor; import org.springframework.expression.spel.support.StandardEvaluationContext; @@ -29,10 +26,10 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.time.Clock; import java.time.Instant; -import java.util.AbstractMap; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; @@ -42,6 +39,7 @@ import java.util.Objects; import java.util.Set; import java.util.TreeMap; +import java.util.concurrent.Executor; import java.util.concurrent.Future; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -85,9 +83,12 @@ public class ThrottleAspect { private final Clock clock; + private final Executor transactionExecutor; + @Autowired - public ThrottleAspect(TaskScheduler taskScheduler) { + public ThrottleAspect(TaskScheduler taskScheduler, TransactionExecutor transactionExecutor) { this.taskScheduler = taskScheduler; + this.transactionExecutor = transactionExecutor; throttledFutures = new HashMap<>(); lastRun = new HashMap<>(); scheduledFutures = new TreeMap<>(); @@ -98,12 +99,13 @@ public ThrottleAspect(TaskScheduler taskScheduler) { protected ThrottleAspect(Map> throttledFutures, Map lastRun, NavigableMap> scheduledFutures, TaskScheduler taskScheduler, - Clock clock) { + Clock clock, TransactionExecutor transactionExecutor) { this.throttledFutures = throttledFutures; this.lastRun = lastRun; this.scheduledFutures = scheduledFutures; this.taskScheduler = taskScheduler; this.clock = clock; + this.transactionExecutor = transactionExecutor; standardEvaluationContext = makeDefaultContext(); } @@ -115,8 +117,8 @@ private static StandardEvaluationContext makeDefaultContext() { final StandardTypeLocator typeLocator = new StandardTypeLocator(loader); final String basePackage = TermItApplication.class.getPackageName(); - Arrays.stream(loader.getDefinedPackages()).map(Package::getName) - .filter(s -> s.indexOf(basePackage) == 0).forEach(typeLocator::registerImport); + Arrays.stream(loader.getDefinedPackages()).map(Package::getName).filter(s -> s.indexOf(basePackage) == 0) + .forEach(typeLocator::registerImport); standardEvaluationContext.setTypeLocator(typeLocator); return standardEvaluationContext; @@ -133,11 +135,22 @@ private static void resolveParameters(Map map, MethodSignature s final String[] paramNames = signature.getParameterNames(); final Object[] params = joinPoint.getArgs(); + if (paramNames == null || params == null || params.length != paramNames.length) { + return; + } + for (int i = 0; i < params.length; i++) { map.putIfAbsent(paramNames[i], params[i]); } } + @SuppressWarnings("unchecked") + private static ThrottledFuture transferTask(@NotNull ThrottledFuture source, + @NotNull ThrottledFuture target) { + // casting the type parameter to Object + return ((ThrottledFuture) source).transfer((ThrottledFuture) target); + } + private EvaluationContext makeContext(JoinPoint joinPoint, Map parameters) { StandardEvaluationContext context = new StandardEvaluationContext(); standardEvaluationContext.applyDelegatesTo(context); @@ -146,19 +159,14 @@ private EvaluationContext makeContext(JoinPoint joinPoint, Map p return context; } - @SuppressWarnings("unchecked") - private static ThrottledFuture transferTask(@NotNull ThrottledFuture source, - @NotNull ThrottledFuture target) { - // casting the type parameter to Object - return ((ThrottledFuture) source).transfer((ThrottledFuture) target); - } - - private @NotNull AbstractMap.SimpleImmutableEntry> getFutureTask( - @NotNull ProceedingJoinPoint joinPoint, Identifier identifier, @NotNull ThrottledFuture future) + private Pair> getFutureTask(@NotNull ProceedingJoinPoint joinPoint, + @NotNull Identifier identifier, + @NotNull ThrottledFuture future) throws Throwable { final Supplier securityContext = SecurityContextHolder.getDeferredContext(); - final Class returnType = ((MethodSignature) joinPoint.getSignature()).getReturnType(); + final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + final Class returnType = methodSignature.getReturnType(); final boolean isFuture = returnType.isAssignableFrom(ThrottledFuture.class); ThrottledFuture throttledFuture = future; @@ -175,12 +183,12 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture if (result instanceof ThrottledFuture throttledMethodFuture) { // future acquired by key or a new future supplied, ensuring the same type // ThrottledFuture#updateOther will create a new future when required - throttledFuture = transferTask(throttledMethodFuture, future); + throttledFuture = transferTask(throttledMethodFuture, throttledFuture); } else { throw new ThrottleAspectException("Returned value is not a ThrottledFuture"); } } else { - future.update(() -> { + throttledFuture.update(() -> { try { return joinPoint.proceed(); } catch (Throwable e) { @@ -190,15 +198,19 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture }); } + final boolean withTransaction = methodSignature.getMethod() != null && methodSignature.getMethod() + .isAnnotationPresent(Transactional.class); + + final ThrottledFuture finalFuture = throttledFuture; // create a task which will be scheduled with executor final Runnable toSchedule = () -> { - if (future.isCancelled() || future.isDone()) { + if (finalFuture.isCancelled() || finalFuture.isDone()) { return; } // mark the thread as throttled final Long threadId = Thread.currentThread().getId(); throttledThreads.add(threadId); - LOG.trace("Running throttled task '{}'", identifier); + LOG.trace("Running throttled task [{} left] '{}'", scheduledFutures.size(), identifier); // restore the security context SecurityContextHolder.setContext(securityContext.get()); try { @@ -207,7 +219,11 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture lastRun.put(identifier, Instant.now(clock)); } // fulfill the future - future.run(); + if (withTransaction) { + transactionExecutor.execute(finalFuture::run); + } else { + finalFuture.run(); + } } finally { // clear the security context SecurityContextHolder.clearContext(); @@ -217,7 +233,7 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture } }; - return new AbstractMap.SimpleImmutableEntry<>(toSchedule, throttledFuture); + return new Pair<>(toSchedule, throttledFuture); } /** @@ -267,33 +283,37 @@ private static ThrottledFuture transferTask(@NotNull ThrottledFuture // cancel the scheduled task // -> the execution is further delayed Future oldFuture = scheduledFutures.get(identifier); - boolean throttleNotExpired = lastRun.getOrDefault(identifier, Instant.EPOCH) - .isAfter(Instant.now(clock).minus(THROTTLE_THRESHOLD)); - if (oldFuture != null && throttleNotExpired) { + boolean throttleExpired = lastRun.getOrDefault(identifier, Instant.EPOCH) + .isBefore(Instant.now(clock).minus(THROTTLE_THRESHOLD)); + if (oldFuture != null && !throttleExpired) { oldFuture.cancel(false); } // acquire a throttled future from a map, or make a new one ThrottledFuture oldThrottledFuture = throttledFutures.getOrDefault(identifier, new ThrottledFuture<>()); - AbstractMap.SimpleImmutableEntry> entry = getFutureTask(joinPoint, identifier, oldThrottledFuture); - Runnable task = entry.getKey(); - ThrottledFuture future = entry.getValue(); + Pair> pair = getFutureTask(joinPoint, identifier, oldThrottledFuture); + Runnable task = pair.getFirst(); + ThrottledFuture future = pair.getSecond(); // update the throttled future in the map throttledFutures.put(identifier, future); Object result = resultVoidOrFuture(signature, future); if (oldFuture == null || oldFuture.isDone() || oldFuture.isCancelled()) { - schedule(identifier, task); + schedule(identifier, task, throttleExpired); } return result; } @SuppressWarnings("unchecked") - private void schedule(Identifier identifier, Runnable task) { - Future scheduled = taskScheduler.schedule(task, Instant.now(clock).plus(THROTTLE_THRESHOLD)); + private void schedule(Identifier identifier, Runnable task, boolean immediately) { + Instant startTime = Instant.now(clock).plus(THROTTLE_THRESHOLD); + if (immediately) { + startTime = Instant.now(clock); + } + Future scheduled = taskScheduler.schedule(task, startTime); // casting the type parameter to Object scheduledFutures.put(identifier, (Future) scheduled); } @@ -320,7 +340,7 @@ private void cancelWithHigherGroup(Identifier throttleAnnotation) { } scheduledFutures.remove(higherKey); - throttledFutures.remove(higherKey); +// throttledFutures.remove(higherKey); higherKey = scheduledFutures.higherKey(higherKey); } @@ -375,41 +395,6 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati } } - /** - * Resolves properties to map values - * - * @param rootClass a class this accessor should accept as target's class - * @param map the map with values - */ - private record MapPropertyAccessor(Class rootClass, Map map) implements PropertyAccessor { - - @Override - public Class[] getSpecificTargetClasses() { - return new Class[]{rootClass, map.getClass()}; - } - - @Override - public boolean canRead(@NotNull EvaluationContext context, Object target, @NotNull String name) { - return map.containsKey(name); - } - - @Override - public @NotNull TypedValue read(@NotNull EvaluationContext context, Object target, @NotNull String name) { - return new TypedValue(map.get(name)); - } - - @Override - public boolean canWrite(@NotNull EvaluationContext context, Object target, @NotNull String name) { - return false; - } - - @Override - public void write(@NotNull EvaluationContext context, Object target, @NotNull String name, Object newValue) - throws AccessException { - throw new AccessException("Unsupported operation"); - } - } - /** * A composed identifier of a throttled instance. *
    
    @@ -418,7 +403,7 @@ public void write(@NotNull EvaluationContext context, Object target, @NotNull St
          * 
    * Implements comparable, first comparing group, then identifier. */ - protected static class Identifier extends ComparablePair { + protected static class Identifier extends Pair.Comparable { public Identifier(String group, String identifier) { super(group, identifier); diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java index a72885098..741cb2c94 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java @@ -2,6 +2,7 @@ import java.net.URI; +@SuppressWarnings("unused") public class ThrottleGroupProvider { private ThrottleGroupProvider() { diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/TransactionExecutor.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/TransactionExecutor.java new file mode 100644 index 000000000..e155db1f4 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/TransactionExecutor.java @@ -0,0 +1,22 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.Executor; + +/** + * Executes the runnable in a transaction + * + * @see Transactional + */ +@Component +public class TransactionExecutor implements Executor { + + @Transactional + @Override + public void execute(@NotNull Runnable command) { + command.run(); + } +} diff --git a/src/main/resources/spring-aop.xml b/src/main/resources/spring-aop.xml index 0bf22ed53..a33bec64d 100644 --- a/src/main/resources/spring-aop.xml +++ b/src/main/resources/spring-aop.xml @@ -9,7 +9,7 @@ AOP related definitions - + diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/AnnotationGeneratorTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/AnnotationGeneratorTest.java index e94fca73f..ab844ce6e 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/AnnotationGeneratorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/AnnotationGeneratorTest.java @@ -193,7 +193,7 @@ void generateAnnotationsThrowsAnnotationGenerationExceptionForUnsupportedFileTyp try (final InputStream content = loadFile("application.yml")) { file.setLabel(generateIncompatibleFile()); final AnnotationGenerationException ex = assertThrows(AnnotationGenerationException.class, - () -> sut.generateAnnotations(content, file)); + () -> sut.generateAnnotations(content, file)); assertThat(ex.getMessage(), containsString("Unsupported type of file")); } } @@ -224,7 +224,7 @@ void generateAnnotationsResolvesOverlappingAnnotations() throws Exception { void generateAnnotationsThrowsAnnotationGenerationExceptionForUnknownTermIdentifier() throws Exception { final InputStream content = setUnknownTermIdentifier(loadFile("data/rdfa-simple.html")); final AnnotationGenerationException ex = assertThrows(AnnotationGenerationException.class, - () -> sut.generateAnnotations(content, file)); + () -> sut.generateAnnotations(content, file)); assertThat(ex.getMessage(), containsString("Term with id ")); assertThat(ex.getMessage(), containsString("not found")); } @@ -434,7 +434,7 @@ void repeatedAnnotationGenerationDoesNotOverwriteConfirmedAnnotations() throws E void generateAnnotationsCreatesAnnotationsForOccurrencesInTermDefinition() { // This is the term in whose definition were discovered by text analysis is their target final Term source = Generator.generateTermWithId(); - sut.generateAnnotations(loadFile("data/rdfa-simple.html"), source); + sut.generateAnnotationsSync(loadFile("data/rdfa-simple.html"), source); final List result = findAllOccurrencesOf(term); assertEquals(1, result.size()); result.forEach(occ -> assertEquals(source.getUri(), occ.getTarget().getSource())); @@ -444,7 +444,7 @@ void generateAnnotationsCreatesAnnotationsForOccurrencesInTermDefinition() { void generateAnnotationsCreatesAnnotationsWithSuggestedStateForOccurrencesInTermDefinition() { // This is the term in whose definition were discovered by text analysis is their target final Term source = Generator.generateTermWithId(); - sut.generateAnnotations(loadFile("data/rdfa-simple.html"), source); + sut.generateAnnotationsSync(loadFile("data/rdfa-simple.html"), source); final List result = findAllOccurrencesOf(term); result.forEach(occ -> assertThat(occ.getTypes(), hasItem(Vocabulary.s_c_navrzeny_vyskyt_termu))); } diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java index 560d6ddd0..cd7a88ea8 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java @@ -357,7 +357,7 @@ void analyzeTermDefinitionInvokesAnnotationGeneratorWithResultFromTextAnalysisSe sut.analyzeTermDefinition(term, vocabulary.getUri()); final ArgumentCaptor captor = ArgumentCaptor.forClass(InputStream.class); - verify(annotationGeneratorMock).generateAnnotations(captor.capture(), eq(term)); + verify(annotationGeneratorMock).generateAnnotationsSync(captor.capture(), eq(term)); final String result = new BufferedReader(new InputStreamReader(captor.getValue())).lines().collect( Collectors.joining("\n")); assertEquals(CONTENT, result); @@ -371,7 +371,7 @@ void analyzeTermDefinitionDoesNotInvokeTextAnalysisServiceWhenDefinitionInConfig assertNotEquals(term.getDefinition().getLanguages(), Collections.singleton(Environment.LANGUAGE)); sut.analyzeTermDefinition(term, vocabulary.getUri()); mockServer.verify(); - verify(annotationGeneratorMock, never()).generateAnnotations(any(), any(Term.class)); + verify(annotationGeneratorMock, never()).generateAnnotationsSync(any(), any(Term.class)); } @Test @@ -382,7 +382,7 @@ void analyzeTermDefinitionDoesNothingWhenTextAnalysisServiceUrlIsNotConfigured() sut.analyzeTermDefinition(term, vocabulary.getUri()); mockServer.verify(); - verify(annotationGeneratorMock, never()).generateAnnotations(any(), any(Term.class)); + verify(annotationGeneratorMock, never()).generateAnnotationsSync(any(), any(Term.class)); verify(textAnalysisRecordDao, never()).persist(any()); } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java new file mode 100644 index 000000000..010670a84 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java @@ -0,0 +1,67 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.lang.reflect.Method; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +// intentionally not enabling test profile +@ExtendWith(MockitoExtension.class) +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {ThrottleAspectTestContextConfig.class}, + initializers = {ConfigDataApplicationContextInitializer.class}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class ThrottleAspectBeanTest { + + @Autowired + ThreadPoolTaskScheduler taskScheduler; + + @SpyBean + ThrottleAspect throttleAspect; + + @Autowired + ThrottledService throttledService; + + @BeforeEach + void beforeEach() { + reset(taskScheduler); + when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).then(invocation -> { + Runnable task = invocation.getArgument(0, Runnable.class); + return new MockedFutureTask<>(task, null); + }); + } + + @Test + void throttleAspectIsCreated() { + assertNotNull(throttleAspect); + } + + @Test + void aspectIsCalledWhenThrottleAnnotationIsPresent() throws Throwable { + throttledService.annotatedMethod(); + + final Method method = ThrottledService.class.getMethod("annotatedMethod"); + final Throttle annotation = method.getAnnotation(Throttle.class); + assertNotNull(annotation); + + verify(throttleAspect).throttleMethodCall(any(), eq(annotation)); + } + +} diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index 8b7ca0160..87b7685f3 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.scheduling.TaskScheduler; +import org.springframework.test.annotation.DirtiesContext; import java.time.Clock; import java.time.Duration; @@ -41,6 +42,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class ThrottleAspectTest { OrderedMap> throttledFutures; @@ -51,6 +53,8 @@ class ThrottleAspectTest { TaskScheduler taskScheduler; + TransactionExecutor transactionExecutor; + OrderedMap taskSchedulerTasks; ThrottleAspect sut; @@ -96,7 +100,6 @@ void mockB() throws Throwable { when(joinPointB.getArgs()).thenReturn(new Object[]{Map.of("first", "firstValue", "second", "secondValue")}); when(joinPointB.getTarget()).thenReturn(this); - throttleB = new MockedThrottle("{#paramName.get('second'), #paramName.get('first')}", "'my.testing.group.B'"); } @@ -134,7 +137,13 @@ void beforeEach() throws Throwable { Clock mockedClock = mock(Clock.class); when(mockedClock.instant()).then(invocation -> getInstant()); - sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock); + transactionExecutor = mock(TransactionExecutor.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(transactionExecutor).execute(any(Runnable.class)); + + sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock, transactionExecutor); } Instant getInstant() { @@ -187,7 +196,7 @@ void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { assertNotNull(scheduledAt); assertNotNull(scheduledTask); // the task should be scheduled at the first call - assertEquals(firstCall.plus(THROTTLE_THRESHOLD), scheduledAt); + assertEquals(firstCall, scheduledAt); final ThrottledFuture future = throttledFutures.getValue(0); assertNotNull(future); @@ -225,29 +234,40 @@ void callsInThrottleIntervalAreMerged() throws Throwable { assertEquals(result1, result2); } + @SuppressWarnings("unchecked") @Test void schedulesNewFutureWhenTheOldOneIsCompleted() throws Throwable { - sut.throttleMethodCall(joinPointA, throttleA); + doReturn(Future.class).when(signatureA).getReturnType(); + when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(()->"result")); + Future firstFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); addSecond(); - final AbstractMap.SimpleImmutableEntry> firstEntry = new AbstractMap.SimpleImmutableEntry<>(scheduledFutures.firstEntry()); - final Runnable firstTask = taskSchedulerTasks.getKey(0); - assertNotNull(firstTask); - firstTask.run(); + assertNotNull(firstFuture); + assertFalse(firstFuture.isDone()); + assertFalse(firstFuture.isCancelled()); - sut.throttleMethodCall(joinPointA, throttleA); + assertEquals(1, taskSchedulerTasks.size()); + taskSchedulerTasks.forEach((runnable, instant) -> runnable.run()); + taskSchedulerTasks.clear(); + assertTrue(firstFuture.isDone()); + assertFalse(firstFuture.isCancelled()); + + Future secondFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); addSecond(); - assertEquals(1, scheduledFutures.size()); - assertEquals(2, taskSchedulerTasks.size()); + assertNotNull(secondFuture); + assertFalse(secondFuture.isDone()); + assertFalse(secondFuture.isCancelled()); - assertTrue(firstEntry.getValue().isDone()); + assertEquals(1, taskSchedulerTasks.size()); + taskSchedulerTasks.forEach((runnable, instant) -> runnable.run()); + taskSchedulerTasks.clear(); + assertTrue(secondFuture.isDone()); + assertFalse(secondFuture.isCancelled()); - final Future secondFuture = scheduledFutures.get(firstEntry.getKey()); - assertNotEquals(firstEntry.getValue(), secondFuture); + assertNotEquals(firstFuture, secondFuture); - assertFalse(secondFuture.isDone()); - assertFalse(secondFuture.isCancelled()); + assertEquals(1, scheduledFutures.size()); } @Test @@ -357,7 +377,7 @@ void aspectDoesThrowWhenMethodReturnsNullByValueAndFutureBySignature() throws Th @ValueSource(classes = {Future.class, ThrottledFuture.class}) void aspectThrowsWhenMethodDoesNotReturnsThrottledFutureObject(Class returnType) throws Throwable { signatureA.setReturnType(returnType); - when(joinPointA.proceed()).thenReturn(new FutureTask<>(()->"")); + when(joinPointA.proceed()).thenReturn(new FutureTask<>(() -> "")); assertThrows(ThrottleAspectException.class, () -> sut.throttleMethodCall(joinPointA, throttleA)); } @@ -382,4 +402,24 @@ void aspectResolvesThrottleGroupProviderClassInSpEL() throws Throwable { assertEquals(expectedGroup, resolvedGroup); } + @Test + void exceptionPropagatedWhenJoinPointProceedThrows() throws Throwable { + when(joinPointA.proceed()).thenThrow(new RuntimeException()); + + sut.throttleMethodCall(joinPointA, throttleA); + + assertThrows(RuntimeException.class, () -> taskSchedulerTasks.forEach((r, i) -> r.run())); + } + + @Test + void exceptionPropagatedFutureTask() throws Throwable { + when(joinPointA.proceed()).then(invocation -> new ThrottledFuture<>().update(() -> { + throw new RuntimeException(); + })); + signatureA.setReturnType(Future.class); + + sut.throttleMethodCall(joinPointA, throttleA); + + assertThrows(RuntimeException.class, () -> taskSchedulerTasks.forEach((r, i) -> r.run())); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java new file mode 100644 index 000000000..764bc0cc5 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java @@ -0,0 +1,30 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.context.annotation.ImportResource; +import org.springframework.context.annotation.aspectj.EnableSpringConfigured; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +import static org.mockito.Answers.RETURNS_SMART_NULLS; + +@TestConfiguration +@EnableSpringConfigured +@ImportResource("classpath*:spring-aop.xml") +@EnableAspectJAutoProxy(proxyTargetClass = true) +@ComponentScan(value = "cz.cvut.kbss.termit.util.throttle") +public class ThrottleAspectTestContextConfig { + + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + return Mockito.mock(ThreadPoolTaskScheduler.class, RETURNS_SMART_NULLS); + } + + @Bean + public ThrottledService throttledService() { + return new ThrottledService(); + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledService.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledService.java new file mode 100644 index 000000000..d89cd7149 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledService.java @@ -0,0 +1,11 @@ +package cz.cvut.kbss.termit.util.throttle; + +import java.util.concurrent.Future; + +public class ThrottledService { + + @Throttle + public Future annotatedMethod() { + return ThrottledFuture.of(() -> true); + } +} From 28c0102ce15bf3955ab9644211efab469a53db8b Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 22 Aug 2024 14:34:48 +0200 Subject: [PATCH 083/150] disable sync term saver --- .../rest/handler/RestExceptionHandler.java | 13 ++++++-- .../service/document/AnnotationGenerator.java | 9 ++---- .../AsynchronousTermOccurrenceSaver.java | 2 +- .../util/longrunning/LongRunningTask.java | 14 +++++++++ .../longrunning/LongRunningTaskRegister.java | 10 ++++++ .../termit/util/throttle/ThrottleAspect.java | 15 +++++++-- .../termit/util/throttle/ThrottledFuture.java | 31 ++++++++++++++----- 7 files changed, 76 insertions(+), 18 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index 4056966f3..fe488e8ff 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -20,6 +20,7 @@ import cz.cvut.kbss.jopa.exceptions.EntityNotFoundException; import cz.cvut.kbss.jopa.exceptions.OWLPersistenceException; import cz.cvut.kbss.jsonld.exception.JsonLdException; +import cz.cvut.kbss.jsonld.exception.JsonLdSerializationException; import cz.cvut.kbss.termit.exception.AnnotationGenerationException; import cz.cvut.kbss.termit.exception.AssetRemovalException; import cz.cvut.kbss.termit.exception.AuthorizationException; @@ -282,7 +283,15 @@ public ResponseEntity invalidIdentifierException(HttpServletRequest r } @ExceptionHandler - public void asyncRequestNotUsableException(HttpServletRequest request, AsyncRequestNotUsableException e) { - LOG.error("Client closed connection when processing request to {}", request.getRequestURI()); + public void asyncRequestNotUsableException(HttpServletRequest request, JsonLdSerializationException e) { + Throwable cause = e.getCause(); + while (cause != null && !cause.getCause().equals(cause)) { + if (cause instanceof AsyncRequestNotUsableException) { + LOG.error("Client closed connection when processing request to {}", request.getRequestURI()); + return; + } + cause = cause.getCause(); + } + throw e; } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index 0407e6a41..60d2740bd 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -25,6 +25,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -48,17 +49,13 @@ public class AnnotationGenerator { private final TermOccurrenceSaver occurrenceSaver; - private final SynchronousTermOccurrenceSaver synchronousTermOccurrenceSaver; - @Autowired public AnnotationGenerator(DocumentManager documentManager, TermOccurrenceResolvers resolvers, - TermOccurrenceSaver occurrenceSaver, - SynchronousTermOccurrenceSaver synchronousTermOccurrenceSaver) { + TermOccurrenceSaver occurrenceSaver) { this.documentManager = documentManager; this.resolvers = resolvers; this.occurrenceSaver = occurrenceSaver; - this.synchronousTermOccurrenceSaver = synchronousTermOccurrenceSaver; } /** @@ -107,7 +104,7 @@ public void generateAnnotationsSync(InputStream content, AbstractTerm annotatedT LOG.debug("Resolving annotations of the definition of {}.", annotatedTerm); occurrenceResolver.parseContent(content, annotatedTerm); final List occurrences = occurrenceResolver.findTermOccurrences(); - synchronousTermOccurrenceSaver.saveOccurrences(occurrences, annotatedTerm); + occurrenceSaver.saveOccurrences(occurrences, annotatedTerm); LOG.trace("Finished generating annotations for the definition of {}.", annotatedTerm); } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java index a12186af0..1d92e2aad 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java @@ -16,7 +16,7 @@ */ @Primary @Service -@Profile("!test") +@Profile("!test & disabled") public class AsynchronousTermOccurrenceSaver implements TermOccurrenceSaver { private static final Logger LOG = LoggerFactory.getLogger(AsynchronousTermOccurrenceSaver.class); diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java new file mode 100644 index 000000000..724541dbf --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java @@ -0,0 +1,14 @@ +package cz.cvut.kbss.termit.util.longrunning; + +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; + +public interface LongRunningTask { + + boolean isRunning(); + boolean isCompleted(); + + @Nullable + Instant runningSince(); +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java new file mode 100644 index 000000000..5fcd7f644 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java @@ -0,0 +1,10 @@ +package cz.cvut.kbss.termit.util.longrunning; + +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; + +public interface LongRunningTaskRegister { + @NotNull + Collection getTasks(); +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index e2fe41f9f..fcd5fb1e8 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -3,6 +3,8 @@ import cz.cvut.kbss.termit.TermItApplication; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.ThrottleAspectException; +import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; +import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskRegister; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; @@ -34,6 +36,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.NavigableMap; import java.util.Objects; @@ -54,7 +57,7 @@ @Scope(SCOPE_SINGLETON) @Component("throttleAspect") @Profile("!test") -public class ThrottleAspect { +public class ThrottleAspect implements LongRunningTaskRegister { private static final Logger LOG = LoggerFactory.getLogger(ThrottleAspect.class); @@ -210,7 +213,10 @@ private Pair> getFutureTask(@NotNull Proceedin // mark the thread as throttled final Long threadId = Thread.currentThread().getId(); throttledThreads.add(threadId); - LOG.trace("Running throttled task [{} left] '{}'", scheduledFutures.size(), identifier); + LOG.atTrace() + .addArgument(() -> scheduledFutures.values().stream().filter(f -> !f.isDone() && !f.isCancelled()) + .count()).addArgument(identifier) + .log("Running throttled task [{} left] '{}'"); // restore the security context SecurityContextHolder.setContext(securityContext.get()); try { @@ -395,6 +401,11 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati } } + @Override + public @NotNull Collection getTasks() { + return List.copyOf(throttledFutures.values()); + } + /** * A composed identifier of a throttled instance. *
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java
    index 5c129390e..7d80df2c5 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java
    @@ -1,8 +1,11 @@
     package cz.cvut.kbss.termit.util.throttle;
     
    +import cz.cvut.kbss.termit.util.Utils;
    +import cz.cvut.kbss.termit.util.longrunning.LongRunningTask;
     import org.jetbrains.annotations.NotNull;
     import org.jetbrains.annotations.Nullable;
     
    +import java.time.Instant;
     import java.util.concurrent.CompletableFuture;
     import java.util.concurrent.ExecutionException;
     import java.util.concurrent.Future;
    @@ -10,7 +13,7 @@
     import java.util.concurrent.TimeoutException;
     import java.util.function.Supplier;
     
    -public class ThrottledFuture implements Future {
    +public class ThrottledFuture implements Future, LongRunningTask {
     
         private final Object lock = new Object();
     
    @@ -21,7 +24,7 @@ public class ThrottledFuture implements Future {
         /**
          * Access only with acquired {@link #lock}
          */
    -    private boolean completing = false;
    +    private @Nullable Instant completingSince = null;
     
         private ThrottledFuture(@NotNull final Supplier task) {
             this.task = task;
    @@ -77,7 +80,6 @@ public T get(long timeout, @NotNull TimeUnit unit)
                 throws InterruptedException, ExecutionException, TimeoutException {
             return future.get(timeout, unit);
         }
    -
         /**
          * @param task the new task
          * @return If the current task is already running, was canceled or already completed, returns a new future for the given task.
    @@ -85,7 +87,7 @@ public T get(long timeout, @NotNull TimeUnit unit)
          */
         protected ThrottledFuture update(Supplier task) {
             synchronized (lock) {
    -            if (completing || future.isCancelled() || future.isDone()) {
    +            if (isRunning() || future.isCancelled() || future.isDone()) {
                     return ThrottledFuture.of(task);
                 }
                 this.task = task;
    @@ -104,7 +106,7 @@ protected ThrottledFuture update(Supplier task) {
          */
         protected ThrottledFuture transfer(ThrottledFuture target) {
             synchronized (lock) {
    -            if (completing || future.isCancelled() || future.isDone()) {
    +            if (isRunning() || future.isCancelled() || future.isDone()) {
                     return target;
                 }
     
    @@ -116,10 +118,10 @@ protected ThrottledFuture transfer(ThrottledFuture target) {
     
         protected void run() {
             synchronized (lock) {
    -            if (completing || future.isCancelled() || future.isDone()) {
    +            if (isRunning() || future.isCancelled() || future.isDone()) {
                     return;
                 }
    -            completing = true;
    +            completingSince = Utils.timestamp();
             }
     
             if (task != null) {
    @@ -128,4 +130,19 @@ protected void run() {
                 future.complete(null);
             }
         }
    +
    +    @Override
    +    public boolean isRunning() {
    +        return completingSince != null;
    +    }
    +
    +    @Override
    +    public boolean isCompleted() {
    +        return isDone() && isCancelled();
    +    }
    +
    +    @Override
    +    public @Nullable Instant runningSince() {
    +        return completingSince;
    +    }
     }
    
    From 0539a67bb5c76f63ad517d192f9e75dddb1e37b8 Mon Sep 17 00:00:00 2001
    From: lukaskabc 
    Date: Fri, 23 Aug 2024 10:08:39 +0200
    Subject: [PATCH 084/150] async mvc
    
    ---
     .../cz/cvut/kbss/termit/config/AppConfig.java |  10 +-
     .../cvut/kbss/termit/config/WebAppConfig.java |  23 ++-
     .../kbss/termit/rest/ResourceController.java  |  79 ++++----
     .../cvut/kbss/termit/rest/TermController.java |  62 +++---
     .../termit/rest/VocabularyController.java     |  29 ++-
     .../rest/handler/RestExceptionHandler.java    |  15 --
     .../SynchronousTermOccurrenceSaver.java       |   1 +
     .../cvut/kbss/termit/util/Configuration.java  |  15 ++
     .../kbss/termit/util/throttle/Throttle.java   |   7 +
     .../termit/util/throttle/ThrottleAspect.java  | 182 +++++++++++++-----
     .../termit/util/throttle/MockedThrottle.java  |  11 ++
     11 files changed, 291 insertions(+), 143 deletions(-)
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java
    index 4619a211c..79cbeb008 100644
    --- a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java
    +++ b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java
    @@ -46,10 +46,14 @@ public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
             return new AsyncExceptionHandler();
         }
     
    -    @Bean
    -    public ThreadPoolTaskScheduler threadPoolTaskScheduler() {
    +    /**
    +     * This thread pool is responsible for executing asynchronous REST controller methods
    +     * and any asynchronous task in the application.
    +     */
    +    @Bean(destroyMethod = "destroy")
    +    public ThreadPoolTaskScheduler threadPoolTaskScheduler(cz.cvut.kbss.termit.util.Configuration config) {
             ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
    -        threadPoolTaskScheduler.setPoolSize(1); // TODO config value
    +        threadPoolTaskScheduler.setPoolSize(config.getAsyncThreadCount());
             threadPoolTaskScheduler.setThreadNamePrefix("TermItScheduler-");
             threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true);
             threadPoolTaskScheduler.setRemoveOnCancelPolicy(true);
    diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java
    index 61781c11a..8d7bd811e 100644
    --- a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java
    +++ b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java
    @@ -47,8 +47,10 @@
     import org.springframework.http.converter.ResourceHttpMessageConverter;
     import org.springframework.http.converter.StringHttpMessageConverter;
     import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
    +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
     import org.springframework.web.bind.annotation.RestController;
     import org.springframework.web.method.HandlerTypePredicate;
    +import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
     import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
     import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
     import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping;
    @@ -62,13 +64,15 @@
     @Configuration
     public class WebAppConfig implements WebMvcConfigurer {
     
    -    private final cz.cvut.kbss.termit.util.Configuration.Repository config;
    +    private final cz.cvut.kbss.termit.util.Configuration config;
    +    private final ThreadPoolTaskScheduler scheduler;
     
         @Value("${application.version:development}")
         private String version;
     
    -    public WebAppConfig(cz.cvut.kbss.termit.util.Configuration config) {
    -        this.config = config.getRepository();
    +    public WebAppConfig(cz.cvut.kbss.termit.util.Configuration config, ThreadPoolTaskScheduler threadPoolTaskScheduler) {
    +        this.config = config;
    +        this.scheduler = threadPoolTaskScheduler;
         }
     
         @Bean(name = "objectMapper")
    @@ -133,10 +137,11 @@ public ServletWrappingController sparqlEndpointController() throws Exception {
             controller.setServletClass(AdjustedUriTemplateProxyServlet.class);
             controller.setBeanName("sparqlEndpointProxyServlet");
             final Properties p = new Properties();
    -        p.setProperty("targetUri", config.getUrl());
    +        final cz.cvut.kbss.termit.util.Configuration.Repository repository = config.getRepository();
    +        p.setProperty("targetUri", repository.getUrl());
             p.setProperty("log", "false");
    -        p.setProperty(ConfigParam.REPO_USERNAME.toString(), config.getUsername() != null ? config.getUsername() : "");
    -        p.setProperty(ConfigParam.REPO_PASSWORD.toString(), config.getPassword() != null ? config.getPassword() : "");
    +        p.setProperty(ConfigParam.REPO_USERNAME.toString(), repository.getUsername() != null ? repository.getUsername() : "");
    +        p.setProperty(ConfigParam.REPO_PASSWORD.toString(), repository.getPassword() != null ? repository.getPassword() : "");
             controller.setInitParameters(p);
             controller.afterPropertiesSet();
             return controller;
    @@ -200,4 +205,10 @@ public OpenAPI customOpenAPI() {
                                 .info(new Info().title("TermIt REST API").description("TermIt REST API definition.")
                                                 .version(version));
         }
    +
    +    @Override
    +    public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
    +        configurer.setDefaultTimeout((long) 1000 * 60 * 2);
    +        configurer.setTaskExecutor(scheduler);
    +    }
     }
    diff --git a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java
    index f389a328a..8a7e0f529 100644
    --- a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java
    +++ b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java
    @@ -64,6 +64,7 @@
     import java.util.List;
     import java.util.Optional;
     import java.util.Set;
    +import java.util.concurrent.Callable;
     
     @Tag(name = "Resources", description = "Resource management API")
     @RestController
    @@ -129,7 +130,7 @@ public void updateResource(@Parameter(description = ResourceControllerDoc.ID_LOC
                 @ApiResponse(responseCode = "404", description = "Resource not found or its content is not stored.")
         })
         @GetMapping(value = "/{localName}/content")
    -    public ResponseEntity getContent(
    +    public Callable> getContent(
                 @Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION,
                            example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE)
                 @PathVariable String localName,
    @@ -144,24 +145,26 @@ public ResponseEntity getContent(
                 @Parameter(description = "Whether to return the content without unconfirmed term occurrences.")
                 @RequestParam(name = "withoutUnconfirmedOccurrences",
                               required = false) boolean withoutUnconfirmedOccurrences) {
    -        final Resource resource = getResource(localName, namespace);
    -        try {
    -            final Optional timestamp = at.map(RestUtils::parseTimestamp);
    -            final TypeAwareResource content = resourceService.getContent(resource,
    -                                                                         new ResourceRetrievalSpecification(timestamp,
    -                                                                                                            withoutUnconfirmedOccurrences));
    -            final ResponseEntity.BodyBuilder builder = ResponseEntity.ok()
    -                                                                     .contentLength(content.contentLength())
    -                                                                     .contentType(MediaType.parseMediaType(
    -                                                                             content.getMediaType()
    -                                                                                    .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE)));
    -            if (asAttachment) {
    -                builder.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + localName + "\"");
    +        return () -> {
    +            final Resource resource = getResource(localName, namespace);
    +            try {
    +                final Optional timestamp = at.map(RestUtils::parseTimestamp);
    +                final TypeAwareResource content = resourceService.getContent(resource,
    +                        new ResourceRetrievalSpecification(timestamp,
    +                                withoutUnconfirmedOccurrences));
    +                final ResponseEntity.BodyBuilder builder = ResponseEntity.ok()
    +                                                                         .contentLength(content.contentLength())
    +                                                                         .contentType(MediaType.parseMediaType(
    +                                                                                 content.getMediaType()
    +                                                                                        .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE)));
    +                if (asAttachment) {
    +                    builder.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + localName + "\"");
    +                }
    +                return builder.body(content);
    +            } catch (IOException e) {
    +                throw new TermItException("Unable to load content of resource " + resource, e);
                 }
    -            return builder.body(content);
    -        } catch (IOException e) {
    -            throw new TermItException("Unable to load content of resource " + resource, e);
    -        }
    +        };
         }
     
         @Operation(security = {@SecurityRequirement(name = "bearer-key")},
    @@ -172,7 +175,7 @@ public ResponseEntity getContent(
         })
         @PutMapping(value = "/{localName}/content")
         @ResponseStatus(HttpStatus.NO_CONTENT)
    -    public void saveContent(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION,
    +    public Callable saveContent(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION,
                                            example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE)
                                 @PathVariable String localName,
                                 @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION,
    @@ -180,15 +183,18 @@ public void saveContent(@Parameter(description = ResourceControllerDoc.ID_LOCAL_
                                 @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace,
                                 @Parameter(description = "File with the new content.")
                                 @RequestParam(name = "file") MultipartFile attachment) {
    -        final Resource resource = getResource(localName, namespace);
    -        try {
    -            resourceService.saveContent(resource, attachment.getInputStream());
    -        } catch (IOException e) {
    -            throw new TermItException(
    -                    "Unable to read file (fileName=\"" + attachment.getOriginalFilename() + "\") content from request.",
    -                    e);
    -        }
    -        LOG.debug("Content saved for resource {}.", resource);
    +        return () -> {
    +            final Resource resource = getResource(localName, namespace);
    +            try {
    +                resourceService.saveContent(resource, attachment.getInputStream());
    +            } catch (IOException e) {
    +                throw new TermItException(
    +                        "Unable to read file (fileName=\"" + attachment.getOriginalFilename() + "\") content from request.",
    +                        e);
    +            }
    +            LOG.debug("Content saved for resource {}.", resource);
    +            return null;
    +        };
         }
     
         @Operation(security = {@SecurityRequirement(name = "bearer-key")},
    @@ -305,20 +311,23 @@ public void removeFileFromDocument(@Parameter(description = ResourceControllerDo
         })
         @PutMapping(value = "/{localName}/text-analysis")
         @ResponseStatus(HttpStatus.NO_CONTENT)
    -    public void runTextAnalysis(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION,
    -                                           example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE)
    +    public Callable runTextAnalysis(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION,
    +                                                     example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE)
                                     @PathVariable String localName,
    -                                @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION,
    +                                          @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION,
                                                example = ResourceControllerDoc.ID_NAMESPACE_EXAMPLE)
                                     @RequestParam(name = QueryParams.NAMESPACE,
                                                   required = false) Optional namespace,
    -                                @Parameter(
    +                                          @Parameter(
                                             description = "Identifiers of vocabularies whose terms are used to seed text analysis.")
                                     @RequestParam(name = "vocabulary", required = false,
                                                   defaultValue = "") Set vocabularies) {
    -        final Resource resource = getResource(localName, namespace);
    -        resourceService.runTextAnalysis(resource, vocabularies);
    -        LOG.debug("Text analysis finished for resource {}.", resource);
    +        return () -> {
    +            final Resource resource = getResource(localName, namespace);
    +            resourceService.runTextAnalysis(resource, vocabularies);
    +            LOG.debug("Text analysis finished for resource {}.", resource);
    +            return null;
    +        };
         }
     
         @Operation(security = {@SecurityRequirement(name = "bearer-key")},
    diff --git a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java
    index beec58274..de21ebc51 100644
    --- a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java
    +++ b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java
    @@ -72,6 +72,7 @@
     import java.util.List;
     import java.util.Optional;
     import java.util.Set;
    +import java.util.concurrent.Callable;
     
     import static cz.cvut.kbss.termit.rest.util.RestUtils.createPageRequest;
     
    @@ -210,7 +211,7 @@ private void verifyAcceptType(String acceptType) {
         })
         @PreAuthorize("permitAll()")
         @RequestMapping(method = RequestMethod.HEAD, value = "/vocabularies/{localName}/terms")
    -    public ResponseEntity checkTerms(
    +    public Callable> checkTerms(
                 @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE)
                 @PathVariable String localName,
                 @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, example = ApiDoc.ID_NAMESPACE_EXAMPLE)
    @@ -220,14 +221,16 @@ public ResponseEntity checkTerms(
                 @Parameter(description = "Language of the label.")
                 @RequestParam(name = "language", required = false) String language) {
             final URI vocabularyUri = getVocabularyUri(namespace, localName);
    -        final Vocabulary vocabulary = termService.getVocabularyReference(vocabularyUri);
    -        if (prefLabel != null) {
    -            final boolean exists = termService.existsInVocabulary(prefLabel, vocabulary, language);
    -            return new ResponseEntity<>(exists ? HttpStatus.OK : HttpStatus.NOT_FOUND);
    -        } else {
    -            final Integer count = termService.getTermCount(vocabulary);
    -            return ResponseEntity.ok().header(Constants.X_TOTAL_COUNT_HEADER, count.toString()).build();
    -        }
    +        return () -> {
    +            final Vocabulary vocabulary = termService.getVocabularyReference(vocabularyUri);
    +            if (prefLabel != null) {
    +                final boolean exists = termService.existsInVocabulary(prefLabel, vocabulary, language);
    +                return new ResponseEntity<>(exists ? HttpStatus.OK : HttpStatus.NOT_FOUND);
    +            } else {
    +                final Integer count = termService.getTermCount(vocabulary);
    +                return ResponseEntity.ok().header(Constants.X_TOTAL_COUNT_HEADER, count.toString()).build();
    +            }
    +        };
         }
     
         private Vocabulary getVocabulary(URI vocabularyUri) {
    @@ -256,7 +259,7 @@ private Vocabulary getVocabulary(URI vocabularyUri) {
         })
         @GetMapping(value = "/vocabularies/{localName}/terms/roots",
                     produces = {MediaType.APPLICATION_JSON_VALUE, JsonLd.MEDIA_TYPE})
    -    public List getAllRoots(
    +    public Callable> getAllRoots(
                 @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE)
                 @PathVariable String localName,
                 @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, example = ApiDoc.ID_NAMESPACE_EXAMPLE)
    @@ -270,11 +273,13 @@ public List getAllRoots(
                 @Parameter(
                         description = "Identifiers of terms that should be included in the response (regardless of whether they are root terms or not).")
                 @RequestParam(name = "includeTerms", required = false, defaultValue = "") List includeTerms) {
    -        final Vocabulary vocabulary = getVocabulary(getVocabularyUri(namespace, localName));
    -        return includeImported ?
    -               termService
    -                       .findAllRootsIncludingImported(vocabulary, createPageRequest(pageSize, pageNo), includeTerms) :
    -               termService.findAllRoots(vocabulary, createPageRequest(pageSize, pageNo), includeTerms);
    +        return () -> {
    +            final Vocabulary vocabulary = getVocabulary(getVocabularyUri(namespace, localName));
    +            return includeImported ?
    +                    termService
    +                            .findAllRootsIncludingImported(vocabulary, createPageRequest(pageSize, pageNo), includeTerms) :
    +                    termService.findAllRoots(vocabulary, createPageRequest(pageSize, pageNo), includeTerms);
    +        };
         }
     
         @Operation(security = {@SecurityRequirement(name = "bearer-key")},
    @@ -354,7 +359,7 @@ private URI getTermUri(String vocabIdFragment, String termIdFragment, Optional update(
                 @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE)
                 @PathVariable String localName,
                 @Parameter(description = ApiDoc.ID_TERM_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_TERM_LOCAL_NAME_EXAMPLE)
    @@ -365,8 +370,11 @@ public void update(
                 @RequestBody Term term) {
             final URI termUri = getTermUri(localName, termLocalName, namespace);
             verifyRequestAndEntityIdentifier(term, termUri);
    -        termService.update(term);
    -        LOG.debug("Term {} updated.", term);
    +        return () -> {
    +            termService.update(term);
    +            LOG.debug("Term {} updated.", term);
    +            return null;
    +        };
         }
     
         @Operation(security = {@SecurityRequirement(name = "bearer-key")},
    @@ -378,7 +386,7 @@ public void update(
         })
         @PutMapping(value = "/terms/{localName}", consumes = {MediaType.APPLICATION_JSON_VALUE, JsonLd.MEDIA_TYPE})
         @ResponseStatus(HttpStatus.NO_CONTENT)
    -    public void update(
    +    public Callable update(
                 @Parameter(description = ApiDoc.ID_STANDALONE_LOCAL_NAME_DESCRIPTION,
                            example = ApiDoc.ID_TERM_LOCAL_NAME_EXAMPLE)
                 @PathVariable String localName,
    @@ -389,8 +397,11 @@ public void update(
                 @RequestBody Term term) {
             final URI termUri = idResolver.resolveIdentifier(namespace, localName);
             verifyRequestAndEntityIdentifier(term, termUri);
    -        termService.update(term);
    -        LOG.debug("Term {} updated.", term);
    +        return () -> {
    +            termService.update(term);
    +            LOG.debug("Term {} updated.", term);
    +            return null;
    +        };
         }
     
         @Operation(security = {@SecurityRequirement(name = "bearer-key")},
    @@ -601,15 +612,18 @@ public List getDefinitionallyRelatedTermsOf(
         })
         @PutMapping(value = "/vocabularies/{localName}/terms/{termLocalName}/text-analysis")
         @ResponseStatus(HttpStatus.NO_CONTENT)
    -    public void runTextAnalysisOnTerm(
    +    public Callable runTextAnalysisOnTerm(
                 @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE)
                 @PathVariable String localName,
                 @Parameter(description = ApiDoc.ID_TERM_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_TERM_LOCAL_NAME_EXAMPLE)
                 @PathVariable String termLocalName,
                 @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, example = ApiDoc.ID_NAMESPACE_EXAMPLE)
                 @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace) {
    -        termService.analyzeTermDefinition(getById(localName, termLocalName, namespace),
    -                                          getVocabularyUri(namespace, localName));
    +        return () -> {
    +            termService.analyzeTermDefinition(getById(localName, termLocalName, namespace),
    +                    getVocabularyUri(namespace, localName));
    +            return null;
    +        };
         }
     
         @Operation(security = {@SecurityRequirement(name = "bearer-key")},
    diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java
    index cc7c763c6..c318112ca 100644
    --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java
    +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java
    @@ -42,6 +42,7 @@
     import io.swagger.v3.oas.annotations.responses.ApiResponses;
     import io.swagger.v3.oas.annotations.security.SecurityRequirement;
     import io.swagger.v3.oas.annotations.tags.Tag;
    +import org.aspectj.weaver.ast.Call;
     import org.slf4j.Logger;
     import org.slf4j.LoggerFactory;
     import org.springframework.beans.factory.annotation.Autowired;
    @@ -326,14 +327,17 @@ public void updateVocabulary(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCR
                    description = "Runs text analysis on the definitions of all terms in the vocabulary with the specified identifier.")
         @PutMapping(value = "/{localName}/terms/text-analysis")
         @ResponseStatus(HttpStatus.ACCEPTED)
    -    public void runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION,
    +    public Callable runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION,
                                                          example = ApiDoc.ID_LOCAL_NAME_EXAMPLE)
                                               @PathVariable String localName,
                                               @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION,
                                                          example = ApiDoc.ID_NAMESPACE_EXAMPLE)
                                               @RequestParam(name = QueryParams.NAMESPACE,
                                                             required = false) Optional namespace) {
    -        vocabularyService.runTextAnalysisOnAllTerms(getById(localName, namespace));
    +        return () -> {
    +            vocabularyService.runTextAnalysisOnAllTerms(getById(localName, namespace));
    +            return null;
    +        };
         }
     
         /**
    @@ -347,8 +351,11 @@ public void runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc.ID_LOCAL_N
         @GetMapping(value = "/text-analysis")
         @ResponseStatus(HttpStatus.ACCEPTED)
         @PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "')")
    -    public void runTextAnalysisOnAllVocabularies() {
    -        vocabularyService.runTextAnalysisOnAllVocabularies();
    +    public Callable runTextAnalysisOnAllVocabularies() {
    +        return () -> {
    +            vocabularyService.runTextAnalysisOnAllVocabularies();
    +            return null;
    +        };
         }
     
         /**
    @@ -428,7 +435,7 @@ public List termsRelations(@Parameter(description = ApiDoc.ID_LOC
                 @ApiResponse(responseCode = "404", description = ApiDoc.ID_NOT_FOUND_DESCRIPTION)
         })
         @PostMapping("/{localName}/versions")
    -    public ResponseEntity createSnapshot(
    +    public Callable> createSnapshot(
                 @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION,
                            example = ApiDoc.ID_LOCAL_NAME_EXAMPLE)
                 @PathVariable String localName,
    @@ -438,11 +445,13 @@ public ResponseEntity createSnapshot(
                 @RequestParam(name = QueryParams.NAMESPACE,
                               required = false) Optional namespace) {
             final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName);
    -        final Vocabulary vocabulary = vocabularyService.getReference(identifier);
    -        final Snapshot snapshot = vocabularyService.createSnapshot(vocabulary);
    -        LOG.debug("Created snapshot of vocabulary {}.", vocabulary);
    -        return ResponseEntity.created(
    -                locationWithout(generateLocation(snapshot.getUri()), "/" + localName + "/versions")).build();
    +        return () -> {
    +            final Vocabulary vocabulary = vocabularyService.getReference(identifier);
    +            final Snapshot snapshot = vocabularyService.createSnapshot(vocabulary);
    +            LOG.debug("Created snapshot of vocabulary {}.", vocabulary);
    +            return ResponseEntity.created(
    +                    locationWithout(generateLocation(snapshot.getUri()), "/" + localName + "/versions")).build();
    +        };
         }
     
         @Operation(security = {@SecurityRequirement(name = "bearer-key")},
    diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java
    index fe488e8ff..d83ab552d 100644
    --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java
    +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java
    @@ -20,7 +20,6 @@
     import cz.cvut.kbss.jopa.exceptions.EntityNotFoundException;
     import cz.cvut.kbss.jopa.exceptions.OWLPersistenceException;
     import cz.cvut.kbss.jsonld.exception.JsonLdException;
    -import cz.cvut.kbss.jsonld.exception.JsonLdSerializationException;
     import cz.cvut.kbss.termit.exception.AnnotationGenerationException;
     import cz.cvut.kbss.termit.exception.AssetRemovalException;
     import cz.cvut.kbss.termit.exception.AuthorizationException;
    @@ -51,7 +50,6 @@
     import org.springframework.security.core.userdetails.UsernameNotFoundException;
     import org.springframework.web.bind.annotation.ExceptionHandler;
     import org.springframework.web.bind.annotation.RestControllerAdvice;
    -import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
     import org.springframework.web.multipart.MaxUploadSizeExceededException;
     
     /**
    @@ -281,17 +279,4 @@ public ResponseEntity invalidIdentifierException(HttpServletRequest r
             logException(e, request);
             return new ResponseEntity<>(errorInfo(request, e), HttpStatus.CONFLICT);
         }
    -
    -    @ExceptionHandler
    -    public void asyncRequestNotUsableException(HttpServletRequest request, JsonLdSerializationException e) {
    -        Throwable cause = e.getCause();
    -        while (cause != null && !cause.getCause().equals(cause)) {
    -            if (cause instanceof AsyncRequestNotUsableException) {
    -                LOG.error("Client closed connection when processing request to {}", request.getRequestURI());
    -                return;
    -            }
    -            cause = cause.getCause();
    -        }
    -        throw e;
    -    }
     }
    diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java
    index e8ec00613..392fd205f 100644
    --- a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java
    +++ b/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java
    @@ -8,6 +8,7 @@
     import org.springframework.stereotype.Service;
     import org.springframework.transaction.annotation.Transactional;
     
    +import java.util.Collections;
     import java.util.List;
     
     /**
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java
    index 5f43aa236..2ee79a08d 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java
    @@ -56,6 +56,13 @@ public class Configuration {
          * server.
          */
         private String jmxBeanName = "TermItAdminBean";
    +
    +    /**
    +     * The number of threads for thread pool executing asynchronous and long-running tasks.
    +     * @configurationdoc.default The number of processors available to the Java virtual machine.
    +     */
    +    @Min(1)
    +    private Integer asyncThreadCount = Runtime.getRuntime().availableProcessors();
         @Valid
         private Persistence persistence = new Persistence();
         @Valid
    @@ -111,6 +118,14 @@ public void setJmxBeanName(String jmxBeanName) {
             this.jmxBeanName = jmxBeanName;
         }
     
    +    public Integer getAsyncThreadCount() {
    +        return asyncThreadCount;
    +    }
    +
    +    public void setAsyncThreadCount(@Min(1) Integer asyncThreadCount) {
    +        this.asyncThreadCount = asyncThreadCount;
    +    }
    +
         public Persistence getPersistence() {
             return persistence;
         }
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
    index dbced75bc..d8eba4ffb 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
    @@ -2,6 +2,7 @@
     
     import org.jetbrains.annotations.NotNull;
     
    +import java.lang.annotation.Documented;
     import java.lang.annotation.ElementType;
     import java.lang.annotation.Retention;
     import java.lang.annotation.RetentionPolicy;
    @@ -39,6 +40,7 @@
      */
     @Target(ElementType.METHOD)
     @Retention(RetentionPolicy.RUNTIME)
    +@Documented
     public @interface Throttle {
     
         /**
    @@ -72,4 +74,9 @@
          * Blank string disables any group processing.
          */
         @NotNull String group() default "";
    +
    +    /**
    +     * Whether the returned future may be from older call.
    +     */
    +    boolean returnCached() default true;
     }
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java
    index fcd5fb1e8..eae28560a 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java
    @@ -40,12 +40,16 @@
     import java.util.Map;
     import java.util.NavigableMap;
     import java.util.Objects;
    +import java.util.Optional;
     import java.util.Set;
     import java.util.TreeMap;
    +import java.util.concurrent.ConcurrentHashMap;
     import java.util.concurrent.Executor;
     import java.util.concurrent.Future;
    +import java.util.concurrent.atomic.AtomicReference;
     import java.util.function.Supplier;
     import java.util.stream.Collectors;
    +import java.util.stream.Stream;
     
     import static cz.cvut.kbss.termit.util.Constants.THROTTLE_THRESHOLD;
     import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_SINGLETON;
    @@ -63,20 +67,26 @@ public class ThrottleAspect implements LongRunningTaskRegister {
     
         /**
          * group, identifier -> future
    +     * @implSpec Synchronize in the field definition order before modification
          */
         private final Map> throttledFutures;
     
    +    /**
    +     * @implSpec Synchronize in the field definition order before modification
    +     */
         private final Map lastRun;
     
         /**
          * group, identifier -> future
    +     * @implSpec Synchronize in the field definition order before modification
          */
         private final NavigableMap> scheduledFutures;
     
         /**
    -     * synchronize before access
    +     * thread safe set holding identifiers of threads
    +     * currently executing a throttled task
          */
    -    private final Set throttledThreads = new HashSet<>();
    +    private final Set throttledThreads = ConcurrentHashMap.newKeySet();
     
         private final ExpressionParser parser = new SpelExpressionParser();
     
    @@ -88,6 +98,8 @@ public class ThrottleAspect implements LongRunningTaskRegister {
     
         private final Executor transactionExecutor;
     
    +    private final @NotNull AtomicReference lastClear;
    +
         @Autowired
         public ThrottleAspect(TaskScheduler taskScheduler, TransactionExecutor transactionExecutor) {
             this.taskScheduler = taskScheduler;
    @@ -97,6 +109,7 @@ public ThrottleAspect(TaskScheduler taskScheduler, TransactionExecutor transacti
             scheduledFutures = new TreeMap<>();
             clock = Clock.systemUTC(); // used by Instant.now() by default
             standardEvaluationContext = makeDefaultContext();
    +        lastClear = new AtomicReference<>(Instant.now(clock));
         }
     
         protected ThrottleAspect(Map> throttledFutures,
    @@ -110,6 +123,7 @@ protected ThrottleAspect(Map> throttledFutur
             this.clock = clock;
             this.transactionExecutor = transactionExecutor;
             standardEvaluationContext = makeDefaultContext();
    +        lastClear = new AtomicReference<>(Instant.now(clock));
         }
     
         private static StandardEvaluationContext makeDefaultContext() {
    @@ -167,7 +181,6 @@ private Pair> getFutureTask(@NotNull Proceedin
                                                                       @NotNull ThrottledFuture future)
                 throws Throwable {
     
    -        final Supplier securityContext = SecurityContextHolder.getDeferredContext();
             final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
             final Class returnType = methodSignature.getReturnType();
             final boolean isFuture = returnType.isAssignableFrom(ThrottledFuture.class);
    @@ -204,19 +217,31 @@ private Pair> getFutureTask(@NotNull Proceedin
             final boolean withTransaction = methodSignature.getMethod() != null && methodSignature.getMethod()
                                                                                                   .isAnnotationPresent(Transactional.class);
     
    -        final ThrottledFuture finalFuture = throttledFuture;
             // create a task which will be scheduled with executor
    -        final Runnable toSchedule = () -> {
    -            if (finalFuture.isCancelled() || finalFuture.isDone()) {
    +        final Runnable toSchedule = createRunnableToSchedule(throttledFuture, identifier, withTransaction);
    +
    +        return new Pair<>(toSchedule, throttledFuture);
    +    }
    +
    +    private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Identifier identifier, boolean withTransaction) {
    +        final Supplier securityContext = SecurityContextHolder.getDeferredContext();
    +        return () -> {
    +            if (throttledFuture.isCancelled() || throttledFuture.isDone()) {
                     return;
                 }
                 // mark the thread as throttled
                 final Long threadId = Thread.currentThread().getId();
                 throttledThreads.add(threadId);
    +
                 LOG.atTrace()
    -               .addArgument(() -> scheduledFutures.values().stream().filter(f -> !f.isDone() && !f.isCancelled())
    -                                                  .count()).addArgument(identifier)
    +               .addArgument(() -> {
    +                   synchronized (scheduledFutures) {
    +                       return scheduledFutures.values().stream().filter(f -> !f.isDone() && !f.isCancelled())
    +                                              .count();
    +                   }
    +               }).addArgument(identifier)
                    .log("Running throttled task [{} left] '{}'");
    +
                 // restore the security context
                 SecurityContextHolder.setContext(securityContext.get());
                 try {
    @@ -226,20 +251,64 @@ private Pair> getFutureTask(@NotNull Proceedin
                     }
                     // fulfill the future
                     if (withTransaction) {
    -                    transactionExecutor.execute(finalFuture::run);
    +                    transactionExecutor.execute(throttledFuture::run);
                     } else {
    -                    finalFuture.run();
    +                    throttledFuture.run();
                     }
                 } finally {
                     // clear the security context
                     SecurityContextHolder.clearContext();
                     LOG.trace("Throttled task run finished '{}'", identifier);
    +
    +                clearOldFutures();
    +
                     // remove throttled mark
                     throttledThreads.remove(threadId);
                 }
             };
    +    }
     
    -        return new Pair<>(toSchedule, throttledFuture);
    +    private void clearOldFutures() {
    +        // if the last clear was performed less than a threshold ago, skip it for now
    +        Instant last = lastClear.get();
    +        if (last.isAfter(Instant.now(clock).minus(THROTTLE_THRESHOLD))) {
    +            return;
    +        }
    +        if (!lastClear.compareAndSet(last, Instant.now(clock))) {
    +            return;
    +        }
    +        synchronized (throttledFutures) {
    +            synchronized (lastRun) {
    +                synchronized (scheduledFutures) {
    +                    Stream.of(throttledFutures.keySet().stream(), scheduledFutures.keySet().stream(), lastRun.keySet().stream())
    +                          .flatMap(s -> s).distinct().toList() // ensures safe modification of maps
    +                          .forEach(identifier -> {
    +                              if (isThresholdExpired(identifier)) {
    +                                  Optional.ofNullable(throttledFutures.get(identifier)).ifPresent(throttled -> {
    +                                      if (throttled.isDone() || throttled.isCancelled()) {
    +                                          throttledFutures.remove(identifier);
    +                                      }
    +                                  });
    +                                  Optional.ofNullable(scheduledFutures.get(identifier)).ifPresent(scheduled -> {
    +                                      if (scheduled.isDone() || scheduled.isCancelled()) {
    +                                          scheduledFutures.remove(identifier);
    +                                      }
    +                                  });
    +                                  lastRun.remove(identifier);
    +                              }
    +                          });
    +                }
    +            }
    +        }
    +    }
    +
    +    /**
    +     * @return Whether the time when the identifier last run is older than the threshold,
    +     * true when the task had never run
    +     */
    +    private boolean isThresholdExpired(Identifier identifier) {
    +        return lastRun.getOrDefault(identifier, Instant.EPOCH)
    +                      .isBefore(Instant.now(clock).minus(THROTTLE_THRESHOLD));
         }
     
         /**
    @@ -269,28 +338,29 @@ private Pair> getFutureTask(@NotNull Proceedin
             final Identifier identifier = makeIdentifier(joinPoint, throttleAnnotation);
             LOG.trace("Throttling task with key '{}'", identifier);
     
    -        if (!identifier.getGroup().isBlank()) {
    -            // check if there is a task with lower group
    -            // and if so, cancel this task in favor of the lower group
    -            final Map.Entry> lowerEntry = scheduledFutures.lowerEntry(identifier);
    -            if (lowerEntry != null) {
    -                final Future lowerFuture = lowerEntry.getValue();
    -                boolean hasGroupPrefix = identifier.hasGroupPrefix(lowerEntry.getKey().getGroup());
    -                if (hasGroupPrefix && !lowerFuture.isDone() && !lowerFuture.isCancelled()) {
    -                    LOG.trace("Throttling canceled due to scheduled lower task '{}'", lowerEntry.getKey());
    -                    return ThrottledFuture.canceled();
    +        synchronized (scheduledFutures) {
    +            if (!identifier.getGroup().isBlank()) {
    +                // check if there is a task with lower group
    +                // and if so, cancel this task in favor of the lower group
    +                final Map.Entry> lowerEntry = scheduledFutures.lowerEntry(identifier);
    +                if (lowerEntry != null) {
    +                    final Future lowerFuture = lowerEntry.getValue();
    +                    boolean hasGroupPrefix = identifier.hasGroupPrefix(lowerEntry.getKey().getGroup());
    +                    if (hasGroupPrefix && !lowerFuture.isDone() && !lowerFuture.isCancelled()) {
    +                        LOG.trace("Throttling canceled due to scheduled lower task '{}'", lowerEntry.getKey());
    +                        return ThrottledFuture.canceled();
    +                    }
                     }
    -            }
     
    -            cancelWithHigherGroup(identifier);
    +                cancelWithHigherGroup(identifier);
    +            }
             }
     
             // if there is a scheduled task and this throttled instance was executed in the last THROTTLE_THRESHOLD
             // cancel the scheduled task
             // -> the execution is further delayed
             Future oldFuture = scheduledFutures.get(identifier);
    -        boolean throttleExpired = lastRun.getOrDefault(identifier, Instant.EPOCH)
    -                                         .isBefore(Instant.now(clock).minus(THROTTLE_THRESHOLD));
    +        boolean throttleExpired = isThresholdExpired(identifier);
             if (oldFuture != null && !throttleExpired) {
                 oldFuture.cancel(false);
             }
    @@ -302,7 +372,9 @@ private Pair> getFutureTask(@NotNull Proceedin
             Runnable task = pair.getFirst();
             ThrottledFuture future = pair.getSecond();
             // update the throttled future in the map
    -        throttledFutures.put(identifier, future);
    +        synchronized (throttledFutures) {
    +            throttledFutures.put(identifier, future);
    +        }
     
             Object result = resultVoidOrFuture(signature, future);
     
    @@ -319,36 +391,44 @@ private void schedule(Identifier identifier, Runnable task, boolean immediately)
             if (immediately) {
                 startTime = Instant.now(clock);
             }
    -        Future scheduled = taskScheduler.schedule(task, startTime);
    -        // casting the type parameter to Object
    -        scheduledFutures.put(identifier, (Future) scheduled);
    +        synchronized (scheduledFutures) {
    +            Future scheduled = taskScheduler.schedule(task, startTime);
    +            // casting the type parameter to Object
    +            scheduledFutures.put(identifier, (Future) scheduled);
    +        }
         }
     
         private void cancelWithHigherGroup(Identifier throttleAnnotation) {
             if (throttleAnnotation.getGroup().isBlank()) {
                 return;
             }
    -        // look for any futures with higher group
    -        // cancel them and remove from maps
    -        Future higherFuture;
    -        Identifier higherKey = scheduledFutures.higherKey(new Identifier(throttleAnnotation.getGroup(), ""));
    -        while (higherKey != null) {
    -            if (!higherKey.hasGroupPrefix(throttleAnnotation.getGroup()) || higherKey.getGroup()
    -                                                                                     .equals(throttleAnnotation.getGroup())) {
    -                break;
    -            }
    -
    -            higherFuture = scheduledFutures.get(higherKey);
    -            higherFuture.cancel(false);
    -            final ThrottledFuture throttledFuture = throttledFutures.get(higherKey);
    -            if (throttledFuture != null) {
    -                throttledFuture.cancel(false);
    +        synchronized (throttledFutures) {
    +            synchronized (scheduledFutures) {
    +                // look for any futures with higher group
    +                // cancel them and remove from maps
    +                Future higherFuture;
    +                Identifier higherKey = scheduledFutures.higherKey(new Identifier(throttleAnnotation.getGroup(), ""));
    +                while (higherKey != null) {
    +                    if (!higherKey.hasGroupPrefix(throttleAnnotation.getGroup()) || higherKey.getGroup()
    +                                                                                             .equals(throttleAnnotation.getGroup())) {
    +                        break;
    +                    }
    +
    +                    higherFuture = scheduledFutures.get(higherKey);
    +                    higherFuture.cancel(false);
    +                    final ThrottledFuture throttledFuture = throttledFutures.get(higherKey);
    +                    if (throttledFuture != null) {
    +                        throttledFuture.cancel(false);
    +                        if (throttledFuture.isCancelled()) {
    +                            throttledFutures.remove(higherKey);
    +                        }
    +                    }
    +
    +                    scheduledFutures.remove(higherKey);
    +
    +                    higherKey = scheduledFutures.higherKey(higherKey);
    +                }
                 }
    -
    -            scheduledFutures.remove(higherKey);
    -//            throttledFutures.remove(higherKey);
    -
    -            higherKey = scheduledFutures.higherKey(higherKey);
             }
         }
     
    @@ -403,7 +483,9 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati
     
         @Override
         public @NotNull Collection getTasks() {
    -        return List.copyOf(throttledFutures.values());
    +        synchronized (throttledFutures) {
    +            return List.copyOf(throttledFutures.values());
    +        }
         }
     
         /**
    diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java
    index 951f56081..5a9924c6c 100644
    --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java
    +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java
    @@ -10,6 +10,8 @@ public class MockedThrottle implements Throttle {
     
         private String group;
     
    +    private boolean returnCached = false;
    +
         public MockedThrottle(String value, String group) {
             this.value = value;
             this.group = group;
    @@ -25,6 +27,11 @@ public MockedThrottle(String value, String group) {
             return group;
         }
     
    +    @Override
    +    public boolean returnCached() {
    +        return returnCached;
    +    }
    +
         @Override
         public Class annotationType() {
             return Throttle.class;
    @@ -37,4 +44,8 @@ public void setValue(String value) {
         public void setGroup(String group) {
             this.group = group;
         }
    +
    +    public void setReturnCached(boolean returnCached) {
    +        this.returnCached = returnCached;
    +    }
     }
    
    From e87ad1f91321ae2def94d5d0b96cdf8d1a431302 Mon Sep 17 00:00:00 2001
    From: lukaskabc 
    Date: Fri, 23 Aug 2024 15:01:16 +0200
    Subject: [PATCH 085/150] occurrence saving in parallel
    
    ---
     .../service/document/AnnotationGenerator.java | 65 +++++++++++++++----
     .../AsynchronousTermOccurrenceSaver.java      | 42 ------------
     .../SynchronousTermOccurrenceSaver.java       | 40 +++++++++++-
     .../document/TermOccurrenceResolver.java      | 16 +++--
     .../service/document/TermOccurrenceSaver.java | 17 +++++
     .../service/document/TextAnalysisService.java |  4 +-
     .../document/html/HtmlSelectorGenerators.java |  2 +-
     .../html/HtmlTermOccurrenceResolver.java      | 12 ++--
     .../html/TextPositionSelectorGenerator.java   | 28 +++++++-
     .../document/AnnotationGeneratorTest.java     |  4 +-
     .../document/TextAnalysisServiceTest.java     |  6 +-
     .../html/HtmlTermOccurrenceResolverTest.java  | 34 +++++-----
     12 files changed, 176 insertions(+), 94 deletions(-)
     delete mode 100644 src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java
    index 60d2740bd..0470ce965 100644
    --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java
    +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java
    @@ -18,19 +18,24 @@
     package cz.cvut.kbss.termit.service.document;
     
     import cz.cvut.kbss.termit.exception.AnnotationGenerationException;
    +import cz.cvut.kbss.termit.exception.TermItException;
     import cz.cvut.kbss.termit.model.AbstractTerm;
    +import cz.cvut.kbss.termit.model.Asset;
     import cz.cvut.kbss.termit.model.assignment.TermOccurrence;
     import cz.cvut.kbss.termit.model.resource.File;
     import cz.cvut.kbss.termit.util.throttle.Throttle;
     import org.slf4j.Logger;
     import org.slf4j.LoggerFactory;
     import org.springframework.beans.factory.annotation.Autowired;
    -import org.springframework.scheduling.annotation.Async;
     import org.springframework.stereotype.Service;
     import org.springframework.transaction.annotation.Transactional;
     
     import java.io.InputStream;
    -import java.util.List;
    +import java.util.concurrent.ConcurrentLinkedQueue;
    +import java.util.concurrent.ExecutionException;
    +import java.util.concurrent.FutureTask;
    +import java.util.concurrent.atomic.AtomicBoolean;
    +import java.util.function.Consumer;
     
     /**
      * Creates annotations (term occurrences) for vocabulary terms.
    @@ -41,6 +46,8 @@
     @Service
     public class AnnotationGenerator {
     
    +    private static final long THREAD_JOIN_TIMEOUT = 1000L * 60; // 1 minute
    +
         private static final Logger LOG = LoggerFactory.getLogger(AnnotationGenerator.class);
     
         private final DocumentManager documentManager;
    @@ -50,8 +57,7 @@ public class AnnotationGenerator {
         private final TermOccurrenceSaver occurrenceSaver;
     
         @Autowired
    -    public AnnotationGenerator(DocumentManager documentManager,
    -                               TermOccurrenceResolvers resolvers,
    +    public AnnotationGenerator(DocumentManager documentManager, TermOccurrenceResolvers resolvers,
                                    TermOccurrenceSaver occurrenceSaver) {
             this.documentManager = documentManager;
             this.resolvers = resolvers;
    @@ -70,12 +76,50 @@ public void generateAnnotations(InputStream content, File source) {
             LOG.debug("Resolving annotations of file {}.", source);
             occurrenceResolver.parseContent(content, source);
             occurrenceResolver.setExistingOccurrences(occurrenceSaver.getExistingOccurrences(source));
    -        final List occurrences = occurrenceResolver.findTermOccurrences();
    -        saveAnnotatedContent(source, occurrenceResolver.getContent());
    -        occurrenceSaver.saveOccurrences(occurrences, source);
    +        findAndSaveTermOccurrences(source, occurrenceResolver);
             LOG.trace("Finished generating annotations for file {}.", source);
         }
     
    +    /**
    +     * Calls {@link TermOccurrenceResolver#findTermOccurrences(Consumer)} on {@code #occurrenceResolver}
    +     * creating new thread that will save any found occurrence in parallel.
    +     * Saves annotated content ({@link #saveAnnotatedContent(File, InputStream)} when the source is a {@link File}.
    +     */
    +    private void findAndSaveTermOccurrences(Asset source, TermOccurrenceResolver occurrenceResolver) {
    +        AtomicBoolean finished = new AtomicBoolean(false);
    +        final ConcurrentLinkedQueue toSave = new ConcurrentLinkedQueue<>();
    +        FutureTask findTask = new FutureTask<>(() -> {
    +            try {
    +                occurrenceResolver.findTermOccurrences(toSave::add);
    +            } finally {
    +                finished.set(true);
    +            }
    +            return null;
    +        });
    +        Thread finder = new Thread(findTask);
    +        finder.start();
    +
    +        occurrenceSaver.saveFromQueue(source, finished, toSave);
    +
    +        if (source instanceof File sourceFile) {
    +            saveAnnotatedContent(sourceFile, occurrenceResolver.getContent());
    +        }
    +
    +        try {
    +            findTask.get();
    +            finder.join(THREAD_JOIN_TIMEOUT);
    +        } catch (InterruptedException e) {
    +            LOG.error("Thread interrupted while saving annotations of file {}.", source);
    +            Thread.currentThread().interrupt();
    +            throw new TermItException(e);
    +        } catch (ExecutionException e) {
    +            if (e.getCause() instanceof RuntimeException re) {
    +                throw re;
    +            }
    +            throw new TermItException(e);
    +        }
    +    }
    +
         private TermOccurrenceResolver findResolverFor(File file) {
             // This will allow us to potentially support different types of files
             final TermOccurrenceResolver htmlResolver = resolvers.htmlTermOccurrenceResolver();
    @@ -97,14 +141,13 @@ private void saveAnnotatedContent(File file, InputStream input) {
          * @param annotatedTerm Term whose definition was annotated
          */
         @Transactional
    -    @Throttle(value = "{#annotatedTerm.getUri()}")
    -    public void generateAnnotationsSync(InputStream content, AbstractTerm annotatedTerm) {
    +    @Throttle("{#annotatedTerm.getUri()}")
    +    public void generateAnnotations(InputStream content, AbstractTerm annotatedTerm) {
             // We assume the content (text analysis output) is HTML-compatible
             final TermOccurrenceResolver occurrenceResolver = resolvers.htmlTermOccurrenceResolver();
             LOG.debug("Resolving annotations of the definition of {}.", annotatedTerm);
             occurrenceResolver.parseContent(content, annotatedTerm);
    -        final List occurrences = occurrenceResolver.findTermOccurrences();
    -        occurrenceSaver.saveOccurrences(occurrences, annotatedTerm);
    +        findAndSaveTermOccurrences(annotatedTerm, occurrenceResolver);
             LOG.trace("Finished generating annotations for the definition of {}.", annotatedTerm);
         }
     }
    diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java
    deleted file mode 100644
    index 1d92e2aad..000000000
    --- a/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java
    +++ /dev/null
    @@ -1,42 +0,0 @@
    -package cz.cvut.kbss.termit.service.document;
    -
    -import cz.cvut.kbss.termit.model.Asset;
    -import cz.cvut.kbss.termit.model.assignment.TermOccurrence;
    -import org.slf4j.Logger;
    -import org.slf4j.LoggerFactory;
    -import org.springframework.context.annotation.Primary;
    -import org.springframework.context.annotation.Profile;
    -import org.springframework.scheduling.annotation.Async;
    -import org.springframework.stereotype.Service;
    -
    -import java.util.List;
    -
    -/**
    - * Saves term occurrences asynchronously.
    - */
    -@Primary
    -@Service
    -@Profile("!test & disabled")
    -public class AsynchronousTermOccurrenceSaver implements TermOccurrenceSaver {
    -
    -    private static final Logger LOG = LoggerFactory.getLogger(AsynchronousTermOccurrenceSaver.class);
    -
    -    private final SynchronousTermOccurrenceSaver synchronousSaver;
    -
    -    public AsynchronousTermOccurrenceSaver(SynchronousTermOccurrenceSaver synchronousSaver) {
    -        this.synchronousSaver = synchronousSaver;
    -    }
    -
    -    @Async
    -    @Override
    -    public void saveOccurrences(List occurrences, Asset source) {
    -        LOG.debug("Asynchronously saving term occurrences for asset {}.", source);
    -        synchronousSaver.saveOccurrences(occurrences, source);
    -        LOG.trace("Finished saving term occurrences for asset {}.", source);
    -    }
    -
    -    @Override
    -    public List getExistingOccurrences(Asset source) {
    -        return synchronousSaver.getExistingOccurrences(source);
    -    }
    -}
    diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java
    index 392fd205f..e2acf5d17 100644
    --- a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java
    +++ b/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java
    @@ -8,8 +8,15 @@
     import org.springframework.stereotype.Service;
     import org.springframework.transaction.annotation.Transactional;
     
    +import java.util.ArrayList;
     import java.util.Collections;
    +import java.util.Comparator;
    +import java.util.HashMap;
    +import java.util.Iterator;
     import java.util.List;
    +import java.util.Map;
    +import java.util.concurrent.ConcurrentLinkedQueue;
    +import java.util.concurrent.atomic.AtomicBoolean;
     
     /**
      * Saves occurrences synchronously.
    @@ -31,14 +38,43 @@ public SynchronousTermOccurrenceSaver(TermOccurrenceDao termOccurrenceDao) {
         @Override
         public void saveOccurrences(List occurrences, Asset source) {
             LOG.debug("Saving term occurrences for asset {}.", source);
    -        LOG.trace("Removing all existing occurrences in asset {}.", source);
    -        termOccurrenceDao.removeAll(source);
    +        removeAll(source);
             LOG.trace("Persisting new occurrences in {}.", source);
             occurrences.stream().filter(o -> !o.getTerm().equals(source.getUri())).forEach(termOccurrenceDao::persist);
         }
     
    +    private void saveOccurrence(TermOccurrence occurrence, Asset source) {
    +        if (occurrence.getTerm().equals(source.getUri())) {
    +            return;
    +        }
    +        LOG.debug("Saving a term occurrence for asset {}.", source);
    +        termOccurrenceDao.persist(occurrence);
    +    }
    +
    +    @Transactional
    +    @Override
    +    public void saveFromQueue(final Asset source, final AtomicBoolean finished,
    +                               final ConcurrentLinkedQueue toSave) {
    +        removeAll(source);
    +        TermOccurrence occurrence;
    +        while (!finished.get() || !toSave.isEmpty()) {
    +            if (toSave.isEmpty()) {
    +                Thread.yield();
    +            }
    +            occurrence = toSave.poll();
    +            if (occurrence != null) {
    +                saveOccurrence(occurrence, source);
    +            }
    +        }
    +    }
    +
         @Override
         public List getExistingOccurrences(Asset source) {
             return termOccurrenceDao.findAllTargeting(source);
         }
    +
    +    private void removeAll(Asset source) {
    +        LOG.trace("Removing all existing occurrences in asset {}.", source);
    +        termOccurrenceDao.removeAll(source);
    +    }
     }
    diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java
    index 55964f5b3..222248116 100644
    --- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java
    +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java
    @@ -28,9 +28,11 @@
     import cz.cvut.kbss.termit.service.repository.TermRepositoryService;
     
     import java.io.InputStream;
    +import java.io.OutputStream;
     import java.net.URI;
     import java.util.Collections;
     import java.util.List;
    +import java.util.function.Consumer;
     
     /**
      * Base class for resolving term occurrences in an annotated document.
    @@ -49,7 +51,7 @@ protected TermOccurrenceResolver(TermRepositoryService termService) {
          * Parses the specified input into some abstract representation from which new terms and term occurrences can be
          * extracted.
          * 

    - * Note that this method has to be called before calling {@link #findTermOccurrences()}. + * Note that this method has to be called before calling {@link #findTermOccurrences(Consumer)}. * * @param input The input to parse * @param source Original source of the input. Used for term occurrence generation @@ -80,10 +82,10 @@ public void setExistingOccurrences(List existingOccurrences) { *

    * {@link #parseContent(InputStream, Asset)} has to be called prior to this method. * - * @return List of term occurrences identified in the input + * @param resultConsumer the consumer that will be called for each result * @see #parseContent(InputStream, Asset) */ - public abstract List findTermOccurrences(); + public abstract void findTermOccurrences(Consumer resultConsumer); /** * Checks whether this resolver supports the specified source file type. @@ -102,11 +104,11 @@ public void setExistingOccurrences(List existingOccurrences) { */ protected TermOccurrence createOccurrence(URI termUri, Asset source) { final TermOccurrence occurrence; - if (source instanceof File) { - final FileOccurrenceTarget target = new FileOccurrenceTarget((File) source); + if (source instanceof File file) { + final FileOccurrenceTarget target = new FileOccurrenceTarget(file); occurrence = new TermFileOccurrence(termUri, target); - } else if (source instanceof AbstractTerm) { - final DefinitionalOccurrenceTarget target = new DefinitionalOccurrenceTarget((AbstractTerm) source); + } else if (source instanceof AbstractTerm abstractTerm) { + final DefinitionalOccurrenceTarget target = new DefinitionalOccurrenceTarget(abstractTerm); occurrence = new TermDefinitionalOccurrence(termUri, target); } else { throw new IllegalArgumentException("Unsupported term occurrence source " + source); diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java index 85286d4bb..809ffc08f 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java @@ -4,6 +4,8 @@ import cz.cvut.kbss.termit.model.assignment.TermOccurrence; import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; /** * Saves occurrences of terms. @@ -13,6 +15,8 @@ public interface TermOccurrenceSaver { /** * Saves the specified occurrences of terms in the specified asset. *

    + * Removes all existing occurrences. + *

    * Implementations may reuse existing occurrences if they match the provided ones. * * @param occurrences Occurrences to save @@ -20,6 +24,19 @@ public interface TermOccurrenceSaver { */ void saveOccurrences(List occurrences, Asset source); + /** + * Continously saves occurrences from the queue while blocking current thread until + * {@code #finished} is set to {@code true}. + *

    + * Removes all existing occurrences before processing. + * + * @param source Asset in which the terms occur + * @param finished Whether all occurrences were added to the queue + * @param toSave the queue with occurrences to save + */ + void saveFromQueue(final Asset source, final AtomicBoolean finished, + final ConcurrentLinkedQueue toSave); + /** * Gets a list of existing term occurrences in the specified asset. * diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java index 9966a874a..b9b70e355 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java @@ -81,7 +81,7 @@ public TextAnalysisService(RestTemplate restClient, Configuration config, Docume * @param file File whose content shall be analyzed * @param vocabularyContexts Identifiers of repository contexts containing vocabularies intended for text analysis */ - @Throttle("#file.getUri()") + @Throttle("{#file.getUri()}") @Transactional public void analyzeFile(File file, Set vocabularyContexts) { Objects.requireNonNull(file); @@ -191,7 +191,7 @@ private void invokeTextAnalysisOnTerm(AbstractTerm term, TextAnalysisInput input return; } try (final InputStream is = result.get().getInputStream()) { - annotationGenerator.generateAnnotationsSync(is, term); + annotationGenerator.generateAnnotations(is, term); } } catch (WebServiceIntegrationException e) { throw e; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlSelectorGenerators.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlSelectorGenerators.java index bf09c6ba2..2573c9ce5 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlSelectorGenerators.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlSelectorGenerators.java @@ -52,6 +52,6 @@ public HtmlSelectorGenerators(Configuration config) { * @return Set of generated selectors */ public Set generateSelectors(Element... elements) { - return generators.stream().map(g -> g.generateSelector(elements)).collect(Collectors.toSet()); + return generators.parallelStream().map(g -> g.generateSelector(elements)).collect(Collectors.toSet()); } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java index c67c466ca..81578cb4c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java @@ -45,6 +45,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -54,6 +55,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Consumer; /** * Resolves term occurrences from RDFa-annotated HTML document. @@ -152,11 +154,10 @@ private String fullIri(String possiblyPrefixed) { } @Override - public List findTermOccurrences() { + public void findTermOccurrences(Consumer resultConsumer) { assert document != null; final Set visited = new HashSet<>(); final Elements elements = document.getElementsByAttribute(Constants.RDFa.ABOUT); - final List result = new ArrayList<>(elements.size()); final Double scoreThreshold = Double.parseDouble(config.getTextAnalysis().getTermOccurrenceMinScore()); for (Element element : elements) { if (isNotTermOccurrence(element)) { @@ -173,17 +174,17 @@ public List findTermOccurrences() { occurrence.ifPresent(to -> { if (!to.isSuggested()) { // Occurrence already approved in content (from previous manual approval) - result.add(to); + resultConsumer.accept(to); } else if (existsApproved(to)) { LOG.trace("Found term occurrence {} with matching existing approved occurrence.", to); to.markApproved(); // Annotation without score is considered approved by the frontend element.removeAttr(SCORE_ATTRIBUTE); - result.add(to); + resultConsumer.accept(to); } else { if (to.getScore() > scoreThreshold) { LOG.trace("Found term occurrence {}.", to); - result.add(to); + resultConsumer.accept(to); } else { LOG.trace("The confidence score of occurrence {} is lower than the configured threshold {}.", to, scoreThreshold); @@ -191,7 +192,6 @@ public List findTermOccurrences() { } }); } - return result; } private Optional resolveAnnotation(Element rdfaElem, Asset source) { diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java index b2fb792a8..013f0cb93 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java @@ -18,11 +18,17 @@ package cz.cvut.kbss.termit.service.document.html; import cz.cvut.kbss.termit.model.selector.TextPositionSelector; +import org.apache.logging.log4j.util.BiConsumer; +import org.apache.logging.log4j.util.TriConsumer; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; +import org.jsoup.nodes.TextNode; import org.jsoup.select.Elements; +import org.jsoup.select.NodeTraversor; +import org.jsoup.select.NodeVisitor; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; /** * Generates a {@link TextPositionSelector} for the specified elements. @@ -47,15 +53,31 @@ public TextPositionSelector generateSelector(Element... elements) { return selector; } + /** + * @see SelectorGenerator#extractNodeText(Iterable) + * @see Element#wholeText() + * @see TextNode#getWholeText() + */ private int resolveStartPosition(Element element) { final Elements ancestors = element.parents(); Element previous = element; - int counter = 0; + AtomicInteger counter = new AtomicInteger(); for (Element parent : ancestors) { final List previousSiblings = parent.childNodes().subList(0, previous.siblingIndex()); - counter += extractNodeText(previousSiblings).length(); + + for (final Node sibling : previousSiblings) { + NodeVisitor consumer = (node, depth) -> { + if (node instanceof TextNode textNode) { + counter.addAndGet(textNode.getWholeText().length()); + } else if (node.normalName().equals("br")) { + counter.getAndIncrement(); + } + }; + NodeTraversor.traverse(consumer, sibling); + } + previous = parent; } - return counter; + return counter.get(); } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/AnnotationGeneratorTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/AnnotationGeneratorTest.java index ab844ce6e..6e49852a5 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/AnnotationGeneratorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/AnnotationGeneratorTest.java @@ -434,7 +434,7 @@ void repeatedAnnotationGenerationDoesNotOverwriteConfirmedAnnotations() throws E void generateAnnotationsCreatesAnnotationsForOccurrencesInTermDefinition() { // This is the term in whose definition were discovered by text analysis is their target final Term source = Generator.generateTermWithId(); - sut.generateAnnotationsSync(loadFile("data/rdfa-simple.html"), source); + sut.generateAnnotations(loadFile("data/rdfa-simple.html"), source); final List result = findAllOccurrencesOf(term); assertEquals(1, result.size()); result.forEach(occ -> assertEquals(source.getUri(), occ.getTarget().getSource())); @@ -444,7 +444,7 @@ void generateAnnotationsCreatesAnnotationsForOccurrencesInTermDefinition() { void generateAnnotationsCreatesAnnotationsWithSuggestedStateForOccurrencesInTermDefinition() { // This is the term in whose definition were discovered by text analysis is their target final Term source = Generator.generateTermWithId(); - sut.generateAnnotationsSync(loadFile("data/rdfa-simple.html"), source); + sut.generateAnnotations(loadFile("data/rdfa-simple.html"), source); final List result = findAllOccurrencesOf(term); result.forEach(occ -> assertThat(occ.getTypes(), hasItem(Vocabulary.s_c_navrzeny_vyskyt_termu))); } diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java index cd7a88ea8..560d6ddd0 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java @@ -357,7 +357,7 @@ void analyzeTermDefinitionInvokesAnnotationGeneratorWithResultFromTextAnalysisSe sut.analyzeTermDefinition(term, vocabulary.getUri()); final ArgumentCaptor captor = ArgumentCaptor.forClass(InputStream.class); - verify(annotationGeneratorMock).generateAnnotationsSync(captor.capture(), eq(term)); + verify(annotationGeneratorMock).generateAnnotations(captor.capture(), eq(term)); final String result = new BufferedReader(new InputStreamReader(captor.getValue())).lines().collect( Collectors.joining("\n")); assertEquals(CONTENT, result); @@ -371,7 +371,7 @@ void analyzeTermDefinitionDoesNotInvokeTextAnalysisServiceWhenDefinitionInConfig assertNotEquals(term.getDefinition().getLanguages(), Collections.singleton(Environment.LANGUAGE)); sut.analyzeTermDefinition(term, vocabulary.getUri()); mockServer.verify(); - verify(annotationGeneratorMock, never()).generateAnnotationsSync(any(), any(Term.class)); + verify(annotationGeneratorMock, never()).generateAnnotations(any(), any(Term.class)); } @Test @@ -382,7 +382,7 @@ void analyzeTermDefinitionDoesNothingWhenTextAnalysisServiceUrlIsNotConfigured() sut.analyzeTermDefinition(term, vocabulary.getUri()); mockServer.verify(); - verify(annotationGeneratorMock, never()).generateAnnotationsSync(any(), any(Term.class)); + verify(annotationGeneratorMock, never()).generateAnnotations(any(), any(Term.class)); verify(textAnalysisRecordDao, never()).persist(any()); } diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolverTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolverTest.java index bf85ea85e..fdcfd3ed8 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolverTest.java @@ -47,6 +47,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.endsWith; @@ -116,8 +117,7 @@ void findTermOccurrencesExtractsAlsoScoreFromRdfa() { final File file = initFile(); final InputStream is = cz.cvut.kbss.termit.environment.Environment.loadFile("data/rdfa-simple.html"); sut.parseContent(is, file); - final List result = sut.findTermOccurrences(); - result.forEach(to -> { + sut.findTermOccurrences(to -> { assertNotNull(to.getScore()); assertThat(to.getScore(), greaterThan(0.0)); }); @@ -136,8 +136,7 @@ void findTermOccurrencesHandlesRdfaWithoutScore() { final File file = initFile(); final InputStream is = cz.cvut.kbss.termit.environment.Environment.loadFile("data/rdfa-simple-no-score.html"); sut.parseContent(is, file); - final List result = sut.findTermOccurrences(); - result.forEach(to -> assertNull(to.getScore())); + sut.findTermOccurrences(to -> assertNull(to.getScore())); } @Test @@ -147,8 +146,7 @@ void findTermOccurrencesHandlesInvalidScoreInRdfa() { final InputStream is = cz.cvut.kbss.termit.environment.Environment .loadFile("data/rdfa-simple-invalid-score.html"); sut.parseContent(is, file); - final List result = sut.findTermOccurrences(); - result.forEach(to -> assertNull(to.getScore())); + sut.findTermOccurrences(to -> assertNull(to.getScore())); } @Test @@ -162,10 +160,14 @@ void findTermOccurrencesGeneratesOccurrenceUriBasedOnAnnotationAbout() { final File file = initFile(); final InputStream is = cz.cvut.kbss.termit.environment.Environment.loadFile("data/rdfa-simple.html"); sut.parseContent(is, file); - final List result = sut.findTermOccurrences(); - assertEquals(1, result.size()); - assertThat(result.get(0).getUri().toString(), startsWith(file.getUri() + "/" + TermOccurrence.CONTEXT_SUFFIX)); - assertThat(result.get(0).getUri().toString(), endsWith("1")); + AtomicInteger resultSize = new AtomicInteger(0); + sut.findTermOccurrences(to -> { + resultSize.incrementAndGet(); + assertThat(to.getUri().toString(), startsWith(file.getUri() + "/" + TermOccurrence.CONTEXT_SUFFIX)); + assertThat(to.getUri().toString(), endsWith("1")); + }); + assertEquals(1,resultSize.get()); + } @Test @@ -174,8 +176,7 @@ void findTermOccurrencesMarksOccurrencesAsSuggested() { final File file = initFile(); final InputStream is = cz.cvut.kbss.termit.environment.Environment.loadFile("data/rdfa-simple.html"); sut.parseContent(is, file); - final List result = sut.findTermOccurrences(); - result.forEach(to -> assertThat(to.getTypes(), hasItem(Vocabulary.s_c_navrzeny_vyskyt_termu))); + sut.findTermOccurrences(to -> assertThat(to.getTypes(), hasItem(Vocabulary.s_c_navrzeny_vyskyt_termu))); } @Test @@ -191,9 +192,12 @@ void findTermOccurrencesSetsFoundOccurrencesAsApprovedWhenCorrespondingExistingO sut.parseContent(is, file); sut.setExistingOccurrences(List.of(existing)); - final List result = sut.findTermOccurrences(); - assertEquals(1, result.size()); - assertThat(result.get(0).getTypes(), not(hasItem(Vocabulary.s_c_navrzeny_vyskyt_termu))); + AtomicInteger resultSize = new AtomicInteger(0); + sut.findTermOccurrences(to -> { + resultSize.incrementAndGet(); + assertThat(to.getTypes(), not(hasItem(Vocabulary.s_c_navrzeny_vyskyt_termu))); + }); + assertEquals(1, resultSize.get()); final org.jsoup.nodes.Document document = Jsoup.parse(sut.getContent(), StandardCharsets.UTF_8.name(), ""); final Elements annotations = document.select("span[about]"); assertEquals(1, annotations.size()); From 4aa4b7037ccfe1980acae4833e38e0ffe96a1d2f Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Fri, 23 Aug 2024 16:14:50 +0200 Subject: [PATCH 086/150] fix tests --- doc/throttle-debounce.png | Bin 5438 -> 7807 bytes .../termit/rest/VocabularyController.java | 11 +++++----- .../service/document/AnnotationGenerator.java | 1 + .../config/TestRestSecurityConfig.java | 7 ++++++ .../termit/rest/BaseControllerTestRunner.java | 10 +++++++++ .../termit/rest/ResourceControllerTest.java | 19 +++++++---------- .../kbss/termit/rest/TermControllerTest.java | 20 +++++++++--------- .../termit/rest/VocabularyControllerTest.java | 11 ++++++---- 8 files changed, 48 insertions(+), 31 deletions(-) diff --git a/doc/throttle-debounce.png b/doc/throttle-debounce.png index 75fcb8910c061fc609312065bcd06f3edb83d019..e40d8bb183bf656fdce8a19a0f58469134025511 100644 GIT binary patch delta 7128 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 B?Cbyl delta 4740 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�-_Nbm+_kGux>X-D@*uB$1$D=~dgF|A#AlDm1Ss|GL z9q+ePojD=D8|6O0GBipA!roqBv1%n}5wDW!hg1U*ne`~Xoo}lp%w;zQUCf>9HQgq- zqztS>SBfin+wIr$KM9)UUv^}Lx1J%~u1s&OD>;ecoPia){OdTt_qiEDDtoSb~t4#q}b0PmEWm*8qn{1+jekk0@254x|+8z zYrdP7Iqk(iw}?Og#LN5xhwD$$DQZM;)kD#@yB6n1A&7mh&3cF7;PQer;Yw=f7<9SO zw67x)y2XRQ}Ma9Mq0c-!7L7&B4l^_yZmNO``<$WJRj*GeS~T7|qe)@&aNYA!HoNl-%8SSXa5DW`?a-`f-(%_h!**Lx3|Q2 z48x~-=9yB0s8jFh|Ky|bg=!iTp;&$gd-lVE+4vpJ;Z(>N28dGqF9vlQ41Q6W9`s8$~hroyJ*jn{hJlr0PX6gz?YC)|M?F{5s?|`=8X!?tco1 z@FhL_GF&wk9&*QMm2%B6bLGP>OB7*eNJezAmI(Q6pS? zlR>_GO*k&S20$ksvd}&yX_Lq%^3LAaIJL;k@py<|Sg}C?L!b-RFeY@cLJpm!;9*KO zUE5i?UqCja;AMKe`77H{i=p4V*j(kafXTB(w9rm>+G%F<38&1*cF5uDW1Qf%WKS-v z|C(zkftixC^l}IcOvy#U$d~7@706ZHZPN3V;R58fr{xdNLYY`Fj>82BhB*Q)#+!Gn z?7D(^CTsjty{_l%$5;?-7A+jnt1FE8^dw2j;z@F@39}p z+(rj~W){=RI_UFTh*@&oIHl94@d;(Q@S8QqdR~|iZxjI@69F4V8{jI_iV~}bW407W z0Lr7Yq+rzI>31IR1A2;Z6d`v9a}3O2x5=aER4HY-iE^p I)E++n2WnC`DF6Tf diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index c318112ca..0252ebce2 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -42,7 +42,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import org.aspectj.weaver.ast.Call; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -50,8 +49,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.scheduling.annotation.Async; -import org.springframework.scheduling.annotation.AsyncResult; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -64,7 +61,6 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.ServletWebRequest; -import org.springframework.web.context.request.async.DeferredResult; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -74,9 +70,7 @@ import java.util.List; import java.util.Optional; import java.util.concurrent.Callable; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; /** * Vocabulary management REST API. @@ -602,10 +596,15 @@ public AccessLevel getAccessLevel(@Parameter(description = ApiDoc.ID_LOCAL_NAME_ * A couple of constants for the {@link VocabularyController} API documentation. */ public static final class ApiDoc { + public static final String ID_LOCAL_NAME_DESCRIPTION = "Locally (in the context of the specified namespace/default vocabulary namespace) unique part of the vocabulary identifier."; + public static final String ID_LOCAL_NAME_EXAMPLE = "datovy-mpp-3.4"; + public static final String ID_NAMESPACE_DESCRIPTION = "Identifier namespace. Allows to override the default vocabulary identifier namespace."; + public static final String ID_NAMESPACE_EXAMPLE = "http://onto.fel.cvut.cz/ontologies/slovnik/"; + public static final String ID_NOT_FOUND_DESCRIPTION = "Vocabulary with the specified identifier not found."; private ApiDoc() { diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index 0470ce965..3efde1dd3 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -97,6 +97,7 @@ private void findAndSaveTermOccurrences(Asset source, TermOccurrenceResolver return null; }); Thread finder = new Thread(findTask); + finder.setName("AnnotationGenerator-TermOccurrenceFinder"); finder.start(); occurrenceSaver.saveFromQueue(source, finished, toSave); diff --git a/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java b/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java index c5e6fc891..15a2df9de 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java @@ -30,9 +30,11 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.security.authentication.AuthenticationProvider; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * This configuration class is necessary when testing security of REST controllers (e.g., {@link @@ -73,4 +75,9 @@ public AuthenticationProvider authenticationProvider() { public TermItUserDetailsService userDetailsService() { return mock(TermItUserDetailsService.class); } + + @Bean + public ThreadPoolTaskScheduler threadPoolTaskScheduler() { + return mock(ThreadPoolTaskScheduler.class); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java index 4075ec3fd..5cf068f58 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java @@ -24,8 +24,11 @@ import org.springframework.http.HttpHeaders; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; import static cz.cvut.kbss.termit.environment.Environment.createDefaultMessageConverter; import static cz.cvut.kbss.termit.environment.Environment.createJsonLdMessageConverter; @@ -33,6 +36,8 @@ import static cz.cvut.kbss.termit.environment.Environment.createStringEncodingMessageConverter; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; /** * Common configuration for REST controller tests. @@ -58,6 +63,11 @@ public void setUp(Object controller) { .build(); } + protected ResultActions performAsync(RequestBuilder requestBuilder) throws Exception { + MvcResult async = mockMvc.perform(requestBuilder).andExpect(request().asyncStarted()).andReturn(); + return mockMvc.perform(asyncDispatch(async)); + } + protected void setupObjectMappers() { this.objectMapper = Environment.getObjectMapper(); this.jsonLdObjectMapper = Environment.getJsonLdObjectMapper(); diff --git a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java index 1b2021a72..6d5dc2a0a 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java @@ -158,8 +158,7 @@ void getContentReturnsContentOfRequestedFile() throws Exception { final java.io.File content = createTemporaryHtmlFile(); when(resourceServiceMock.getContent(eq(file), any(ResourceRetrievalSpecification.class))) .thenReturn(new TypeAwareFileSystemResource(content, MediaType.TEXT_HTML_VALUE)); - final MvcResult mvcResult = mockMvc - .perform(get(PATH + "/" + FILE_NAME + "/content")) + final MvcResult mvcResult = performAsync(get(PATH + "/" + FILE_NAME + "/content")) .andExpect(status().isOk()).andReturn(); final String resultContent = mvcResult.getResponse().getContentAsString(); assertEquals(HTML_CONTENT, resultContent); @@ -185,7 +184,7 @@ void saveContentSavesContentViaServiceAndReturnsNoContentStatus() throws Excepti final MockMultipartFile upload = new MockMultipartFile("file", file.getLabel(), MediaType.TEXT_HTML_VALUE, Files.readAllBytes(attachment.toPath()) ); - mockMvc.perform(multipart(PATH + "/" + FILE_NAME + "/content").file(upload) + performAsync(multipart(PATH + "/" + FILE_NAME + "/content").file(upload) .with(req -> { req.setMethod(HttpMethod.PUT.toString()); return req; @@ -199,7 +198,7 @@ void runTextAnalysisInvokesTextAnalysisOnSpecifiedResource() throws Exception { final File file = generateFile(); when(identifierResolverMock.resolveIdentifier(RESOURCE_NAMESPACE, FILE_NAME)).thenReturn(file.getUri()); when(resourceServiceMock.findRequired(file.getUri())).thenReturn(file); - mockMvc.perform(put(PATH + "/" + FILE_NAME + "/text-analysis").param(QueryParams.NAMESPACE, RESOURCE_NAMESPACE)) + performAsync(put(PATH + "/" + FILE_NAME + "/text-analysis").param(QueryParams.NAMESPACE, RESOURCE_NAMESPACE)) .andExpect(status().isNoContent()); verify(resourceServiceMock).runTextAnalysis(file, Collections.emptySet()); } @@ -211,7 +210,7 @@ void runTextAnalysisInvokesTextAnalysisWithSpecifiedVocabulariesAsTermSources() when(resourceServiceMock.findRequired(file.getUri())).thenReturn(file); final Set vocabularies = IntStream.range(0, 3).mapToObj(i -> Generator.generateUri().toString()) .collect(Collectors.toSet()); - mockMvc.perform(put(PATH + "/" + FILE_NAME + "/text-analysis").param(QueryParams.NAMESPACE, RESOURCE_NAMESPACE) + performAsync(put(PATH + "/" + FILE_NAME + "/text-analysis").param(QueryParams.NAMESPACE, RESOURCE_NAMESPACE) .param("vocabulary", vocabularies.toArray(new String[0]))) .andExpect(status().isNoContent()); @@ -379,8 +378,8 @@ void getContentSupportsReturningContentAsAttachment() throws Exception { final java.io.File content = createTemporaryHtmlFile(); when(resourceServiceMock.getContent(eq(file), any(ResourceRetrievalSpecification.class))) .thenReturn(new TypeAwareFileSystemResource(content, MediaType.TEXT_HTML_VALUE)); - final MvcResult mvcResult = mockMvc - .perform(get(PATH + "/" + FILE_NAME + "/content").param("attachment", Boolean.toString(true))) + final MvcResult mvcResult = performAsync( + get(PATH + "/" + FILE_NAME + "/content").param("attachment", Boolean.toString(true))) .andExpect(status().isOk()).andReturn(); assertThat(mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION), containsString("attachment")); assertThat(mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION), @@ -420,8 +419,7 @@ void getContentWithTimestampReturnsContentOfRequestedFileAtSpecifiedTimestamp() final Instant at = Utils.timestamp().truncatedTo(ChronoUnit.SECONDS); when(resourceServiceMock.getContent(eq(file), any(ResourceRetrievalSpecification.class))) .thenReturn(new TypeAwareFileSystemResource(content, MediaType.TEXT_HTML_VALUE)); - final MvcResult mvcResult = mockMvc - .perform(get(PATH + "/" + FILE_NAME + "/content") + final MvcResult mvcResult = performAsync(get(PATH + "/" + FILE_NAME + "/content") .queryParam("at", Constants.TIMESTAMP_FORMATTER.format(at))) .andExpect(status().isOk()).andReturn(); final String resultContent = mvcResult.getResponse().getContentAsString(); @@ -454,8 +452,7 @@ void getContentWithoutUnconfirmedOccurrencesReturnsContentOfRequestedFileAtWitho final java.io.File content = createTemporaryHtmlFile(); when(resourceServiceMock.getContent(eq(file), any(ResourceRetrievalSpecification.class))) .thenReturn(new TypeAwareFileSystemResource(content, MediaType.TEXT_HTML_VALUE)); - final MvcResult mvcResult = mockMvc - .perform(get(PATH + "/" + FILE_NAME + "/content") + final MvcResult mvcResult = performAsync(get(PATH + "/" + FILE_NAME + "/content") .queryParam("withoutUnconfirmedOccurrences", Boolean.toString(true))) .andExpect(status().isOk()).andReturn(); final String resultContent = mvcResult.getResponse().getContentAsString(); diff --git a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java index cedfb8e06..d64716588 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java @@ -149,7 +149,7 @@ void termsExistCheckReturnOkIfTermLabelExistsInVocabulary() throws Exception { when(idResolverMock.resolveIdentifier(namespace, VOCABULARY_NAME)).thenReturn(vocabularyUri); when(termServiceMock.getVocabularyReference(vocabularyUri)).thenReturn(vocabulary); when(termServiceMock.existsInVocabulary(any(), any(), any())).thenReturn(true); - mockMvc.perform( + performAsync( head(PATH + VOCABULARY_NAME + "/terms") .param(QueryParams.NAMESPACE, namespace) .param("prefLabel", name) @@ -167,7 +167,7 @@ void termsExistCheckReturn404IfTermLabelDoesNotExistInVocabulary() throws Except when(idResolverMock.resolveIdentifier(namespace, VOCABULARY_NAME)).thenReturn(vocabularyUri); when(termServiceMock.getVocabularyReference(vocabularyUri)).thenReturn(vocabulary); when(termServiceMock.existsInVocabulary(any(), any(), any())).thenReturn(false); - mockMvc.perform( + performAsync( head(PATH + VOCABULARY_NAME + "/terms") .param(QueryParams.NAMESPACE, namespace) .param("prefLabel", name) @@ -193,7 +193,7 @@ void updateUpdatesTerm() throws Exception { final URI termUri = initTermUriResolution(); final Term term = Generator.generateTerm(); term.setUri(termUri); - mockMvc.perform(put(PATH + VOCABULARY_NAME + "/terms/" + TERM_NAME).content(toJson(term)).contentType( + performAsync(put(PATH + VOCABULARY_NAME + "/terms/" + TERM_NAME).content(toJson(term)).contentType( MediaType.APPLICATION_JSON_VALUE)).andExpect(status().isNoContent()); verify(termServiceMock).update(term); } @@ -408,7 +408,7 @@ void getAllRootsLoadsRootsFromCorrectPage() throws Exception { final List terms = termsToDtos(Generator.generateTermsWithIds(5)); when(termServiceMock.findVocabularyRequired(vocabulary.getUri())).thenReturn(vocabulary); when(termServiceMock.findAllRoots(eq(vocabulary), any(Pageable.class), anyCollection())).thenReturn(terms); - mockMvc.perform(get(PATH + VOCABULARY_NAME + "/terms/roots").param(PAGE, "5").param(PAGE_SIZE, "100")) + performAsync(get(PATH + VOCABULARY_NAME + "/terms/roots").param(PAGE, "5").param(PAGE_SIZE, "100")) .andExpect(status().isOk()); final ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); @@ -422,7 +422,7 @@ void getAllRootsCreatesDefaultPageRequestWhenPagingInfoIsNotSpecified() throws E final List terms = termsToDtos(Generator.generateTermsWithIds(5)); when(termServiceMock.findVocabularyRequired(vocabulary.getUri())).thenReturn(vocabulary); when(termServiceMock.findAllRoots(eq(vocabulary), any(Pageable.class), anyCollection())).thenReturn(terms); - mockMvc.perform(get(PATH + VOCABULARY_NAME + "/terms/roots")).andExpect(status().isOk()); + performAsync(get(PATH + VOCABULARY_NAME + "/terms/roots")).andExpect(status().isOk()); final ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); verify(termServiceMock).findAllRoots(eq(vocabulary), captor.capture(), anyCollection()); @@ -632,7 +632,7 @@ void updateStandaloneUpdatesTerm() throws Exception { final Term term = Generator.generateTerm(); when(idResolverMock.resolveIdentifier(NAMESPACE, TERM_NAME)).thenReturn(termUri); term.setUri(termUri); - mockMvc.perform( + performAsync( put("/terms/" + TERM_NAME).param(QueryParams.NAMESPACE, NAMESPACE).content(toJson(term)).contentType( MediaType.APPLICATION_JSON_VALUE)).andExpect(status().isNoContent()); verify(idResolverMock).resolveIdentifier(NAMESPACE, TERM_NAME); @@ -701,7 +701,7 @@ void getAllRootsWithPageSpecAndIncludeImportsGetsRootTermsIncludingImportedTerms .thenReturn(URI.create(VOCABULARY_URI)); when(termServiceMock.findVocabularyRequired(vocabulary.getUri())).thenReturn(vocabulary); - mockMvc.perform( + performAsync( get(PATH + VOCABULARY_NAME + "/terms/roots").param("includeImported", Boolean.TRUE.toString())) .andExpect(status().isOk()); verify(termServiceMock).findAllRootsIncludingImported(vocabulary, DEFAULT_PAGE_SPEC, Collections.emptyList()); @@ -770,7 +770,7 @@ void runTextAnalysisInvokesTextAnalysisOnService() throws Exception { when(idResolverMock.resolveIdentifier(NAMESPACE, TERM_NAME)).thenReturn(termUri); when(termServiceMock.findRequired(termUri)).thenReturn(toAnalyze); - mockMvc.perform( + performAsync( put(PATH + VOCABULARY_NAME + "/terms/" + TERM_NAME + "/text-analysis")) .andExpect(status().isNoContent()); verify(termServiceMock).analyzeTermDefinition(toAnalyze, URI.create(VOCABULARY_URI)); @@ -852,7 +852,7 @@ void getAllRootsPassesProvidedIdentifiersOfTermsToIncludeToService() throws Exce when(termServiceMock.findVocabularyRequired(vocabulary.getUri())).thenReturn(vocabulary); when(termServiceMock.findAllRoots(eq(vocabulary), any(Pageable.class), anyCollection())).thenReturn(terms); final List toInclude = Arrays.asList(Generator.generateUri(), Generator.generateUri()); - mockMvc.perform(get(PATH + VOCABULARY_NAME + "/terms/roots").param("includeTerms", + performAsync(get(PATH + VOCABULARY_NAME + "/terms/roots").param("includeTerms", toInclude.stream().map(URI::toString) .toArray(String[]::new))) .andExpect(status().isOk()); @@ -1046,7 +1046,7 @@ void checkTermsRetrievesNumberOfTermsInVocabularyWithSpecifiedIdentifier() throw final Integer termCount = Generator.randomInt(0, 200); when(termServiceMock.getTermCount(vocabulary)).thenReturn(termCount); - final MvcResult mvcResult = mockMvc.perform(head(PATH + VOCABULARY_NAME + "/terms")).andExpect(status().isOk()) + final MvcResult mvcResult = performAsync(head(PATH + VOCABULARY_NAME + "/terms")).andExpect(status().isOk()) .andReturn(); final String countHeader = mvcResult.getResponse().getHeader(Constants.X_TOTAL_COUNT_HEADER); assertNotNull(countHeader); diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index 9458024f5..034cdc60a 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -55,6 +55,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.test.web.servlet.MvcResult; import java.io.File; @@ -85,11 +86,13 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ExtendWith(MockitoExtension.class) @@ -414,13 +417,13 @@ void runTextAnalysisOnAllTermsInvokesTextAnalysisOnAllTermsFromService() throws final Vocabulary vocabulary = generateVocabulary(); vocabulary.setUri(VOCABULARY_URI); when(sut.getById(FRAGMENT, Optional.of(NAMESPACE))).thenReturn(vocabulary); - mockMvc.perform(put(PATH + "/" + FRAGMENT + "/terms/text-analysis")).andExpect(status().isAccepted()); + performAsync(put(PATH + "/" + FRAGMENT + "/terms/text-analysis")).andExpect(status().isAccepted()); verify(serviceMock).runTextAnalysisOnAllTerms(vocabulary); } @Test void runTextAnalysisOnAllVocabulariesInvokesTextAnalysisOnAllVocabulariesFromService() throws Exception { - mockMvc.perform(get(PATH + "/text-analysis")).andExpect(status().isAccepted()); + performAsync(get(PATH + "/text-analysis")).andExpect(status().isAccepted()); verify(serviceMock).runTextAnalysisOnAllVocabularies(); } @@ -481,7 +484,7 @@ void createSnapshotCreatesSnapshotOfVocabularyWithSpecifiedIdentification() thro final Vocabulary vocabulary = generateVocabularyAndInitReferenceResolution(); final Snapshot snapshot = Generator.generateSnapshot(vocabulary); when(serviceMock.createSnapshot(any())).thenReturn(snapshot); - mockMvc.perform(post(PATH + "/" + FRAGMENT + "/versions")) + performAsync(post(PATH + "/" + FRAGMENT + "/versions")) .andExpect(status().isCreated()); verify(serviceMock).createSnapshot(vocabulary); } @@ -491,7 +494,7 @@ void createSnapshotReturnsLocationHeaderWithSnapshotApiPath() throws Exception { final Vocabulary vocabulary = generateVocabularyAndInitReferenceResolution(); final Snapshot snapshot = Generator.generateSnapshot(vocabulary); when(serviceMock.createSnapshot(any())).thenReturn(snapshot); - final MvcResult mvcResult = mockMvc.perform(post(PATH + "/" + FRAGMENT + "/versions")) + final MvcResult mvcResult = performAsync(post(PATH + "/" + FRAGMENT + "/versions")) .andExpect(status().isCreated()) .andReturn(); verifyLocationEquals(PATH + "/" + IdentifierResolver.extractIdentifierFragment(snapshot.getUri()), mvcResult); From 00dff873801745ae82247cdebe8ac867ea1b9941 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 26 Aug 2024 17:04:42 +0200 Subject: [PATCH 087/150] use parallel occurrence saving only for files --- .../cz/cvut/kbss/termit/config/AppConfig.java | 3 +- .../cvut/kbss/termit/config/WebAppConfig.java | 40 ++-- .../persistence/dao/TermOccurrenceDao.java | 6 + .../dao/util/ScheduledContextRemover.java | 65 ------- .../kbss/termit/rest/ResourceController.java | 100 +++++----- .../cvut/kbss/termit/rest/TermController.java | 22 +-- .../termit/rest/VocabularyController.java | 16 +- .../service/document/AnnotationGenerator.java | 34 ++-- .../SynchronousTermOccurrenceSaver.java | 46 +++-- .../document/TermOccurrenceResolver.java | 7 +- .../service/document/TermOccurrenceSaver.java | 5 +- .../service/document/TextAnalysisService.java | 1 + .../html/HtmlTermOccurrenceResolver.java | 54 +++--- .../VocabularyRepositoryService.java | 8 +- .../cz/cvut/kbss/termit/util/Constants.java | 6 + .../kbss/termit/util/throttle/Throttle.java | 32 ++-- .../termit/util/throttle/ThrottleAspect.java | 177 +++++++++--------- .../termit/util/throttle/ThrottledFuture.java | 3 + .../dao/util/ScheduledContextRemoverTest.java | 37 ---- .../termit/rest/ResourceControllerTest.java | 8 +- .../kbss/termit/rest/TermControllerTest.java | 14 +- .../util/throttle/MockedMethodSignature.java | 19 +- .../termit/util/throttle/MockedThrottle.java | 14 +- ...tureTask.java => ScheduledFutureTask.java} | 18 +- .../util/throttle/ThrottleAspectBeanTest.java | 2 +- .../util/throttle/ThrottleAspectTest.java | 120 ++++++++++-- 26 files changed, 440 insertions(+), 417 deletions(-) delete mode 100644 src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java delete mode 100644 src/test/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemoverTest.java rename src/test/java/cz/cvut/kbss/termit/util/throttle/{MockedFutureTask.java => ScheduledFutureTask.java} (53%) diff --git a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java index 79cbeb008..fd05fc0c2 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java @@ -47,8 +47,7 @@ public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { } /** - * This thread pool is responsible for executing asynchronous REST controller methods - * and any asynchronous task in the application. + * This thread pool is responsible for executing long-running tasks in the application. */ @Bean(destroyMethod = "destroy") public ThreadPoolTaskScheduler threadPoolTaskScheduler(cz.cvut.kbss.termit.util.Configuration config) { diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java index 8d7bd811e..0cd55b06f 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java @@ -47,7 +47,6 @@ import org.springframework.http.converter.ResourceHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.method.HandlerTypePredicate; import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; @@ -65,20 +64,12 @@ public class WebAppConfig implements WebMvcConfigurer { private final cz.cvut.kbss.termit.util.Configuration config; - private final ThreadPoolTaskScheduler scheduler; @Value("${application.version:development}") private String version; - public WebAppConfig(cz.cvut.kbss.termit.util.Configuration config, ThreadPoolTaskScheduler threadPoolTaskScheduler) { + public WebAppConfig(cz.cvut.kbss.termit.util.Configuration config) { this.config = config; - this.scheduler = threadPoolTaskScheduler; - } - - @Bean(name = "objectMapper") - @Primary - public ObjectMapper objectMapper() { - return createJsonObjectMapper(); } /** @@ -103,11 +94,6 @@ public static ObjectMapper createJsonObjectMapper() { return objectMapper; } - @Bean(name = "jsonLdMapper") - public ObjectMapper jsonLdObjectMapper() { - return createJsonLdObjectMapper(); - } - /** * Creates an {@link ObjectMapper} for processing JSON-LD using the JB4JSON-LD library. *

    @@ -126,6 +112,17 @@ public static ObjectMapper createJsonLdObjectMapper() { return mapper; } + @Bean(name = "objectMapper") + @Primary + public ObjectMapper objectMapper() { + return createJsonObjectMapper(); + } + + @Bean(name = "jsonLdMapper") + public ObjectMapper jsonLdObjectMapper() { + return createJsonLdObjectMapper(); + } + /** * Register the proxy for SPARQL endpoint. * @@ -152,7 +149,7 @@ public SimpleUrlHandlerMapping sparqlQueryControllerMapping() throws Exception { SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); mapping.setOrder(0); final Map urlMap = Collections.singletonMap(Constants.REST_MAPPING_PATH + "/query", - sparqlEndpointController()); + sparqlEndpointController()); mapping.setUrlMap(urlMap); return mapping; } @@ -198,17 +195,16 @@ public FilterRegistrationBean mdcFilter() { @Bean public OpenAPI customOpenAPI() { return new OpenAPI().components(new Components().addSecuritySchemes("bearer-key", - new SecurityScheme().type( - SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))) + new SecurityScheme().type( + SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) .info(new Info().title("TermIt REST API").description("TermIt REST API definition.") .version(version)); } @Override public void configureAsyncSupport(AsyncSupportConfigurer configurer) { - configurer.setDefaultTimeout((long) 1000 * 60 * 2); - configurer.setTaskExecutor(scheduler); + configurer.setDefaultTimeout(Constants.REST_ASYNC_TIMEOUT); } } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java index 6aa08c722..c88f4025c 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java @@ -35,6 +35,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Repository; +import org.springframework.util.StopWatch; import java.net.URI; import java.util.List; @@ -254,9 +255,14 @@ public void removeAll(Asset target) { Objects.requireNonNull(target); final URI sourceContext = TermOccurrence.resolveContext(target.getUri()); + LOG.debug("Removing all occurrences from {}", sourceContext); + final StopWatch stopWatch = new StopWatch(); + stopWatch.start(); em.createNativeQuery("DROP GRAPH ?context") .setParameter("context", sourceContext) .executeUpdate(); + stopWatch.stop(); + LOG.debug("Removed all occurrences from {} in {} ms", sourceContext, stopWatch.getTotalTimeMillis()); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java deleted file mode 100644 index 326fe0ab0..000000000 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java +++ /dev/null @@ -1,65 +0,0 @@ -package cz.cvut.kbss.termit.persistence.dao.util; - -import cz.cvut.kbss.jopa.model.EntityManager; -import cz.cvut.kbss.termit.util.Utils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.NonNull; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.net.URI; -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -/** - * Drops registered repository contexts at scheduled moments. - *

    - * This allows to move time-consuming removal of repository contexts containing a lot of data to times of low system - * activity. - */ -@Component -public class ScheduledContextRemover { - - private static final Logger LOG = LoggerFactory.getLogger(ScheduledContextRemover.class); - - private final EntityManager em; - - private final Set contextsToRemove = new HashSet<>(); - - public ScheduledContextRemover(EntityManager em) { - this.em = em; - } - - /** - * Schedules the specified context identifier for removal at the next execution of the context cleanup. - * - * @param contextUri Identifier of the context to remove - * @see #runContextRemoval() - */ - public synchronized void scheduleForRemoval(@NonNull URI contextUri) { - LOG.debug("Scheduling context {} for removal.", Utils.uriToString(contextUri)); - contextsToRemove.add(Objects.requireNonNull(contextUri)); - } - - /** - * Runs the removal of the registered repository contexts. - *

    - * This method is scheduled and should not be invoked manually. - * - * @see #scheduleForRemoval(URI) - */ - @Transactional - @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) - public synchronized void runContextRemoval() { - LOG.trace("Running scheduled repository context removal."); - contextsToRemove.forEach(g -> { - LOG.trace("Dropping repository context {}.", Utils.uriToString(g)); - em.createNativeQuery("DROP GRAPH ?g").setParameter("g", g).executeUpdate(); - }); - contextsToRemove.clear(); - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java index 8a7e0f529..72ba43dd7 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java @@ -130,7 +130,7 @@ public void updateResource(@Parameter(description = ResourceControllerDoc.ID_LOC @ApiResponse(responseCode = "404", description = "Resource not found or its content is not stored.") }) @GetMapping(value = "/{localName}/content") - public Callable> getContent( + public ResponseEntity getContent( @Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION, example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE) @PathVariable String localName, @@ -145,26 +145,24 @@ public Callable> getContent @Parameter(description = "Whether to return the content without unconfirmed term occurrences.") @RequestParam(name = "withoutUnconfirmedOccurrences", required = false) boolean withoutUnconfirmedOccurrences) { - return () -> { - final Resource resource = getResource(localName, namespace); - try { - final Optional timestamp = at.map(RestUtils::parseTimestamp); - final TypeAwareResource content = resourceService.getContent(resource, - new ResourceRetrievalSpecification(timestamp, - withoutUnconfirmedOccurrences)); - final ResponseEntity.BodyBuilder builder = ResponseEntity.ok() - .contentLength(content.contentLength()) - .contentType(MediaType.parseMediaType( - content.getMediaType() - .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE))); - if (asAttachment) { - builder.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + localName + "\""); - } - return builder.body(content); - } catch (IOException e) { - throw new TermItException("Unable to load content of resource " + resource, e); + final Resource resource = getResource(localName, namespace); + try { + final Optional timestamp = at.map(RestUtils::parseTimestamp); + final TypeAwareResource content = resourceService.getContent(resource, + new ResourceRetrievalSpecification(timestamp, + withoutUnconfirmedOccurrences)); + final ResponseEntity.BodyBuilder builder = ResponseEntity.ok() + .contentLength(content.contentLength()) + .contentType(MediaType.parseMediaType( + content.getMediaType() + .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE))); + if (asAttachment) { + builder.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + localName + "\""); } - }; + return builder.body(content); + } catch (IOException e) { + throw new TermItException("Unable to load content of resource " + resource, e); + } } @Operation(security = {@SecurityRequirement(name = "bearer-key")}, @@ -175,26 +173,24 @@ public Callable> getContent }) @PutMapping(value = "/{localName}/content") @ResponseStatus(HttpStatus.NO_CONTENT) - public Callable saveContent(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION, - example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE) - @PathVariable String localName, - @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION, - example = ResourceControllerDoc.ID_NAMESPACE_EXAMPLE) - @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace, - @Parameter(description = "File with the new content.") - @RequestParam(name = "file") MultipartFile attachment) { - return () -> { - final Resource resource = getResource(localName, namespace); - try { - resourceService.saveContent(resource, attachment.getInputStream()); - } catch (IOException e) { - throw new TermItException( - "Unable to read file (fileName=\"" + attachment.getOriginalFilename() + "\") content from request.", - e); - } - LOG.debug("Content saved for resource {}.", resource); - return null; - }; + public Void saveContent(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION, + example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE) + @PathVariable String localName, + @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION, + example = ResourceControllerDoc.ID_NAMESPACE_EXAMPLE) + @RequestParam(name = QueryParams.NAMESPACE, + required = false) Optional namespace, + @Parameter(description = "File with the new content.") + @RequestParam(name = "file") MultipartFile attachment) { + + final Resource resource = getResource(localName, namespace); + try { + resourceService.saveContent(resource, attachment.getInputStream()); + } catch (IOException e) { + throw new TermItException("Unable to read file (fileName=\"" + attachment.getOriginalFilename() + "\") content from request.", e); + } + LOG.debug("Content saved for resource {}.", resource); + return null; } @Operation(security = {@SecurityRequirement(name = "bearer-key")}, @@ -218,8 +214,8 @@ public ResponseEntity hasContent(@Parameter(description = ResourceControll return ResponseEntity.notFound().build(); } else { final String contentType = resourceService.getContent(r, - new ResourceRetrievalSpecification(Optional.empty(), - false)) + new ResourceRetrievalSpecification(Optional.empty(), + false)) .getMediaType().orElse(null); return ResponseEntity.noContent().header(HttpHeaders.CONTENT_TYPE, contentType).build(); } @@ -313,19 +309,18 @@ public void removeFileFromDocument(@Parameter(description = ResourceControllerDo @ResponseStatus(HttpStatus.NO_CONTENT) public Callable runTextAnalysis(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION, example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE) - @PathVariable String localName, + @PathVariable String localName, @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION, - example = ResourceControllerDoc.ID_NAMESPACE_EXAMPLE) - @RequestParam(name = QueryParams.NAMESPACE, - required = false) Optional namespace, + example = ResourceControllerDoc.ID_NAMESPACE_EXAMPLE) + @RequestParam(name = QueryParams.NAMESPACE, + required = false) Optional namespace, @Parameter( - description = "Identifiers of vocabularies whose terms are used to seed text analysis.") - @RequestParam(name = "vocabulary", required = false, - defaultValue = "") Set vocabularies) { + description = "Identifiers of vocabularies whose terms are used to seed text analysis.") + @RequestParam(name = "vocabulary", required = false, + defaultValue = "") Set vocabularies) { return () -> { final Resource resource = getResource(localName, namespace); resourceService.runTextAnalysis(resource, vocabularies); - LOG.debug("Text analysis finished for resource {}.", resource); return null; }; } @@ -376,10 +371,15 @@ public List getHistory( * A couple of constants for the {@link ResourceController} API documentation. */ private static final class ResourceControllerDoc { + private static final String ID_LOCAL_NAME_DESCRIPTION = "Locally (in the context of the specified namespace/default resource namespace) unique part of the resource identifier."; + private static final String ID_LOCAL_NAME_EXAMPLE = "mpp-draft.html"; + private static final String ID_NAMESPACE_DESCRIPTION = "Identifier namespace. Allows to override the default resource identifier namespace."; + private static final String ID_NAMESPACE_EXAMPLE = "http://onto.fel.cvut.cz/ontologies/zdroj/"; + private static final String ID_NOT_FOUND_DESCRIPTION = "Resource with the specified identifier not found."; } } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java index de21ebc51..9282701eb 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java @@ -211,7 +211,7 @@ private void verifyAcceptType(String acceptType) { }) @PreAuthorize("permitAll()") @RequestMapping(method = RequestMethod.HEAD, value = "/vocabularies/{localName}/terms") - public Callable> checkTerms( + public ResponseEntity checkTerms( @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) @PathVariable String localName, @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, example = ApiDoc.ID_NAMESPACE_EXAMPLE) @@ -221,7 +221,7 @@ public Callable> checkTerms( @Parameter(description = "Language of the label.") @RequestParam(name = "language", required = false) String language) { final URI vocabularyUri = getVocabularyUri(namespace, localName); - return () -> { + final Vocabulary vocabulary = termService.getVocabularyReference(vocabularyUri); if (prefLabel != null) { final boolean exists = termService.existsInVocabulary(prefLabel, vocabulary, language); @@ -230,7 +230,7 @@ public Callable> checkTerms( final Integer count = termService.getTermCount(vocabulary); return ResponseEntity.ok().header(Constants.X_TOTAL_COUNT_HEADER, count.toString()).build(); } - }; + } private Vocabulary getVocabulary(URI vocabularyUri) { @@ -259,7 +259,7 @@ private Vocabulary getVocabulary(URI vocabularyUri) { }) @GetMapping(value = "/vocabularies/{localName}/terms/roots", produces = {MediaType.APPLICATION_JSON_VALUE, JsonLd.MEDIA_TYPE}) - public Callable> getAllRoots( + public List getAllRoots( @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) @PathVariable String localName, @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, example = ApiDoc.ID_NAMESPACE_EXAMPLE) @@ -273,13 +273,13 @@ public Callable> getAllRoots( @Parameter( description = "Identifiers of terms that should be included in the response (regardless of whether they are root terms or not).") @RequestParam(name = "includeTerms", required = false, defaultValue = "") List includeTerms) { - return () -> { - final Vocabulary vocabulary = getVocabulary(getVocabularyUri(namespace, localName)); - return includeImported ? - termService - .findAllRootsIncludingImported(vocabulary, createPageRequest(pageSize, pageNo), includeTerms) : - termService.findAllRoots(vocabulary, createPageRequest(pageSize, pageNo), includeTerms); - }; + + final Vocabulary vocabulary = getVocabulary(getVocabularyUri(namespace, localName)); + return includeImported ? + termService + .findAllRootsIncludingImported(vocabulary, createPageRequest(pageSize, pageNo), includeTerms) : + termService.findAllRoots(vocabulary, createPageRequest(pageSize, pageNo), includeTerms); + } @Operation(security = {@SecurityRequirement(name = "bearer-key")}, diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 0252ebce2..3a462eb95 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -49,6 +49,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Async; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -71,6 +72,7 @@ import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; /** * Vocabulary management REST API. @@ -429,7 +431,7 @@ public List termsRelations(@Parameter(description = ApiDoc.ID_LOC @ApiResponse(responseCode = "404", description = ApiDoc.ID_NOT_FOUND_DESCRIPTION) }) @PostMapping("/{localName}/versions") - public Callable> createSnapshot( + public ResponseEntity createSnapshot( @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) @PathVariable String localName, @@ -439,13 +441,11 @@ public Callable> createSnapshot( @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace) { final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); - return () -> { - final Vocabulary vocabulary = vocabularyService.getReference(identifier); - final Snapshot snapshot = vocabularyService.createSnapshot(vocabulary); - LOG.debug("Created snapshot of vocabulary {}.", vocabulary); - return ResponseEntity.created( - locationWithout(generateLocation(snapshot.getUri()), "/" + localName + "/versions")).build(); - }; + final Vocabulary vocabulary = vocabularyService.getReference(identifier); + final Snapshot snapshot = vocabularyService.createSnapshot(vocabulary); + LOG.debug("Created snapshot of vocabulary {}.", vocabulary); + return ResponseEntity.created( + locationWithout(generateLocation(snapshot.getUri()), "/" + localName + "/versions")).build(); } @Operation(security = {@SecurityRequirement(name = "bearer-key")}, diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index 3efde1dd3..0e31d86f2 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -29,11 +29,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StopWatch; import java.io.InputStream; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; +import java.util.concurrent.SynchronousQueue; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -81,33 +85,39 @@ public void generateAnnotations(InputStream content, File source) { } /** - * Calls {@link TermOccurrenceResolver#findTermOccurrences(Consumer)} on {@code #occurrenceResolver} + * Calls {@link TermOccurrenceResolver#findTermOccurrences(TermOccurrenceResolver.OccurrenceConsumer)} on {@code #occurrenceResolver} * creating new thread that will save any found occurrence in parallel. * Saves annotated content ({@link #saveAnnotatedContent(File, InputStream)} when the source is a {@link File}. */ private void findAndSaveTermOccurrences(Asset source, TermOccurrenceResolver occurrenceResolver) { AtomicBoolean finished = new AtomicBoolean(false); - final ConcurrentLinkedQueue toSave = new ConcurrentLinkedQueue<>(); + // alternatively, SynchronousQueue could be used, but this allows to have some space as buffer + final ArrayBlockingQueue toSave = new ArrayBlockingQueue<>(10); + // not limiting the queue size would result in OutOfMemoryError + FutureTask findTask = new FutureTask<>(() -> { try { - occurrenceResolver.findTermOccurrences(toSave::add); + LOG.trace("Resolving term occurrences for {}.", source); + occurrenceResolver.findTermOccurrences(toSave::put); + LOG.trace("Finished resolving term occurrences for {}.", source); + LOG.trace("Saving term occurrences for {}.", source); + if (source instanceof File sourceFile) { + saveAnnotatedContent(sourceFile, occurrenceResolver.getContent()); + } + LOG.trace("Term occurrences saved for {}.", source); } finally { finished.set(true); } return null; }); Thread finder = new Thread(findTask); - finder.setName("AnnotationGenerator-TermOccurrenceFinder"); + finder.setName("AnnotationGenerator-TermOccurrenceResolver"); finder.start(); occurrenceSaver.saveFromQueue(source, finished, toSave); - if (source instanceof File sourceFile) { - saveAnnotatedContent(sourceFile, occurrenceResolver.getContent()); - } - try { - findTask.get(); + findTask.get(); // propagates exceptions finder.join(THREAD_JOIN_TIMEOUT); } catch (InterruptedException e) { LOG.error("Thread interrupted while saving annotations of file {}.", source); @@ -142,13 +152,13 @@ private void saveAnnotatedContent(File file, InputStream input) { * @param annotatedTerm Term whose definition was annotated */ @Transactional - @Throttle("{#annotatedTerm.getUri()}") - public void generateAnnotations(InputStream content, AbstractTerm annotatedTerm) { + @Throttle(value = "{#annotatedTerm.getUri()}") + public void generateAnnotationsSync(InputStream content, AbstractTerm annotatedTerm) { // We assume the content (text analysis output) is HTML-compatible final TermOccurrenceResolver occurrenceResolver = resolvers.htmlTermOccurrenceResolver(); LOG.debug("Resolving annotations of the definition of {}.", annotatedTerm); occurrenceResolver.parseContent(content, annotatedTerm); - findAndSaveTermOccurrences(annotatedTerm, occurrenceResolver); + occurrenceResolver.findTermOccurrences(o -> occurrenceSaver.saveOccurrence(o, annotatedTerm)); LOG.trace("Finished generating annotations for the definition of {}.", annotatedTerm); } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java index e2acf5d17..53a2ee46e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java @@ -1,5 +1,6 @@ package cz.cvut.kbss.termit.service.document; +import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.assignment.TermOccurrence; import cz.cvut.kbss.termit.persistence.dao.TermOccurrenceDao; @@ -8,14 +9,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.Iterator; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -43,28 +39,42 @@ public void saveOccurrences(List occurrences, Asset source) { occurrences.stream().filter(o -> !o.getTerm().equals(source.getUri())).forEach(termOccurrenceDao::persist); } - private void saveOccurrence(TermOccurrence occurrence, Asset source) { + @Override + public void saveOccurrence(TermOccurrence occurrence, Asset source) { if (occurrence.getTerm().equals(source.getUri())) { return; } - LOG.debug("Saving a term occurrence for asset {}.", source); - termOccurrenceDao.persist(occurrence); + if(!termOccurrenceDao.exists(occurrence.getUri())) { + termOccurrenceDao.persist(occurrence); + } else { + LOG.debug("Occurrence already exists, skipping: {}", occurrence); + } } @Transactional @Override public void saveFromQueue(final Asset source, final AtomicBoolean finished, - final ConcurrentLinkedQueue toSave) { + final BlockingQueue toSave) { + LOG.debug("Saving term occurrences for asset {}.", source); removeAll(source); TermOccurrence occurrence; - while (!finished.get() || !toSave.isEmpty()) { - if (toSave.isEmpty()) { - Thread.yield(); - } - occurrence = toSave.poll(); - if (occurrence != null) { - saveOccurrence(occurrence, source); + long count = 0; + try { + while (!finished.get() || !toSave.isEmpty()) { + if (toSave.isEmpty()) { + Thread.yield(); + } + occurrence = toSave.poll(1, TimeUnit.SECONDS); + if (occurrence != null) { + saveOccurrence(occurrence, source); + count++; + } } + LOG.debug("Saved {} term occurrences for assert {}.", count, source); + } catch (InterruptedException e) { + LOG.error("Thread interrupted while waiting for occurrences to save."); + Thread.currentThread().interrupt(); + throw new TermItException(e); } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java index 222248116..566fdec7b 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java @@ -85,7 +85,7 @@ public void setExistingOccurrences(List existingOccurrences) { * @param resultConsumer the consumer that will be called for each result * @see #parseContent(InputStream, Asset) */ - public abstract void findTermOccurrences(Consumer resultConsumer); + public abstract void findTermOccurrences(OccurrenceConsumer resultConsumer); /** * Checks whether this resolver supports the specified source file type. @@ -116,4 +116,9 @@ protected TermOccurrence createOccurrence(URI termUri, Asset source) { occurrence.markSuggested(); return occurrence; } + + @FunctionalInterface + public static interface OccurrenceConsumer { + void accept(TermOccurrence termOccurrence) throws InterruptedException; + } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java index 809ffc08f..16a0fc4c4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java @@ -4,6 +4,7 @@ import cz.cvut.kbss.termit.model.assignment.TermOccurrence; import java.util.List; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicBoolean; @@ -35,7 +36,9 @@ public interface TermOccurrenceSaver { * @param toSave the queue with occurrences to save */ void saveFromQueue(final Asset source, final AtomicBoolean finished, - final ConcurrentLinkedQueue toSave); + final BlockingQueue toSave); + + void saveOccurrence(TermOccurrence occurrence, Asset source); /** * Gets a list of existing term occurrences in the specified asset. diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java index b9b70e355..c0b693de4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java @@ -88,6 +88,7 @@ public void analyzeFile(File file, Set vocabularyContexts) { final TextAnalysisInput input = createAnalysisInput(file); input.setVocabularyContexts(vocabularyContexts); invokeTextAnalysisOnFile(file, input); + LOG.debug("Text analysis finished for resource {}.", file.getUri()); } private TextAnalysisInput createAnalysisInput(File file) { diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java index 81578cb4c..2983c3c51 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java @@ -18,6 +18,7 @@ package cz.cvut.kbss.termit.service.document.html; import cz.cvut.kbss.termit.exception.AnnotationGenerationException; +import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.assignment.OccurrenceTarget; @@ -45,17 +46,13 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; import java.net.URI; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.function.Consumer; /** * Resolves term occurrences from RDFa-annotated HTML document. @@ -67,15 +64,19 @@ public class HtmlTermOccurrenceResolver extends TermOccurrenceResolver { private static final String BNODE_PREFIX = "_:"; + private static final String SCORE_ATTRIBUTE = "score"; private static final Logger LOG = LoggerFactory.getLogger(HtmlTermOccurrenceResolver.class); private final HtmlSelectorGenerators selectorGenerators; + private final DocumentManager documentManager; + private final Configuration config; private Document document; + private Asset source; private Map prefixes; @@ -154,7 +155,7 @@ private String fullIri(String possiblyPrefixed) { } @Override - public void findTermOccurrences(Consumer resultConsumer) { + public void findTermOccurrences(OccurrenceConsumer resultConsumer) { assert document != null; final Set visited = new HashSet<>(); final Elements elements = document.getElementsByAttribute(Constants.RDFa.ABOUT); @@ -172,23 +173,28 @@ public void findTermOccurrences(Consumer resultConsumer) { LOG.trace("Processing RDFa annotated element {}.", element); final Optional occurrence = resolveAnnotation(element, source); occurrence.ifPresent(to -> { - if (!to.isSuggested()) { - // Occurrence already approved in content (from previous manual approval) - resultConsumer.accept(to); - } else if (existsApproved(to)) { - LOG.trace("Found term occurrence {} with matching existing approved occurrence.", to); - to.markApproved(); - // Annotation without score is considered approved by the frontend - element.removeAttr(SCORE_ATTRIBUTE); - resultConsumer.accept(to); - } else { - if (to.getScore() > scoreThreshold) { - LOG.trace("Found term occurrence {}.", to); + try { + if (!to.isSuggested()) { + // Occurrence already approved in content (from previous manual approval) + resultConsumer.accept(to); + } else if (existsApproved(to)) { + LOG.trace("Found term occurrence {} with matching existing approved occurrence.", to); + to.markApproved(); + // Annotation without score is considered approved by the frontend + element.removeAttr(SCORE_ATTRIBUTE); resultConsumer.accept(to); } else { - LOG.trace("The confidence score of occurrence {} is lower than the configured threshold {}.", - to, scoreThreshold); + if (to.getScore() > scoreThreshold) { + LOG.trace("Found term occurrence {}.", to); + resultConsumer.accept(to); + } else { + LOG.trace("The confidence score of occurrence {} is lower than the configured threshold {}.", to, scoreThreshold); + } } + } catch (InterruptedException e) { + LOG.error("Thread interrupted while resolving term occurrences."); + Thread.currentThread().interrupt(); + throw new TermItException(e); } }); } @@ -226,9 +232,7 @@ private void verifyTermExists(Element rdfaElem, URI termUri, String termId) { return; } if (!termService.exists(termUri)) { - throw new AnnotationGenerationException( - "Term with id " + Utils.uriToString( - termUri) + " denoted by RDFa element '" + rdfaElem + "' not found."); + throw new AnnotationGenerationException("Term with id " + Utils.uriToString(termUri) + " denoted by RDFa element '" + rdfaElem + "' not found."); } existingTermIds.add(termId); } @@ -273,8 +277,8 @@ public boolean supports(Asset source) { return true; } final Optional probedContentType = documentManager.getContentType(sourceFile); - return probedContentType.isPresent() - && (probedContentType.get().equals(MediaType.TEXT_HTML_VALUE) - || probedContentType.get().equals(MediaType.APPLICATION_XHTML_XML_VALUE)); + return probedContentType.isPresent() && (probedContentType.get() + .equals(MediaType.TEXT_HTML_VALUE) || probedContentType.get() + .equals(MediaType.APPLICATION_XHTML_XML_VALUE)); } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index acca3b1bb..a45ab0421 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -320,7 +320,13 @@ private void ensureNoTermRelationsExists(Vocabulary vocabulary) throws AssetRemo } public List validateContents(URI vocabulary) { - return vocabularyDao.validateContents(vocabulary); + try { + return vocabularyDao.validateContents(vocabulary); + } finally { + // we can be sure that validator allocated + // quite a large memory amount which can be cleared now + System.gc(); + } } public Integer getTermCount(Vocabulary vocabulary) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index a85a25972..32f309824 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -57,6 +57,12 @@ public class Constants { */ public static final Duration THROTTLE_THRESHOLD = Duration.ofSeconds(10); + /** + * The amount of millis used as timeout for async REST tasks (REST controllers returning Callable): + * 5 minutes + */ + public static final long REST_ASYNC_TIMEOUT = (long) 1000 * 60 * 5; + /** * Default page size. *

    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java index d8eba4ffb..87c72ea96 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java @@ -1,5 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; +import cz.cvut.kbss.termit.util.Constants; import org.jetbrains.annotations.NotNull; import java.lang.annotation.Documented; @@ -7,25 +8,37 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.concurrent.Future; /** * Indicates that calls to this method will be throttled & debounced. - * Meaning that the action will be executed on the first call of the method, - * then every next call which comes earlier then {@link cz.cvut.kbss.termit.util.Constants#THROTTLE_THRESHOLD} - * will return a pending future which might be resolved by a newer future. - * Futures will be resolved once per {@link cz.cvut.kbss.termit.util.Constants#THROTTLE_THRESHOLD} (+ duration to execute the future task). + *

    + * Body of the annotated method will be executed on the first call of the method, + * then every next call which comes earlier than {@link Constants#THROTTLE_THRESHOLD} + * will return a pending future which might be resolved by a newer call. + * Future will be resolved once per {@link Constants#THROTTLE_THRESHOLD} (+ duration to execute the future). + *

    + * *

    * Every annotated method should be tested for throttling to ensure it has the desired effect. *

    - * Method can't use any parameters that are part of persistent context, they need to be re-requested. + * Method can't use any parameters that are part of persistent context as the method will be executed on separated thread, + * objects need to be re-requested.
    + * Call to this method cannot be part of an existing transaction. *

    * Available only for methods returning {@code void}, {@link Void} and {@link ThrottledFuture}, - * method signature may be {@link java.util.concurrent.Future}, + * method signature may be {@link Future}, * or another type assignable from {@link ThrottledFuture}, * but the returned concrete object has to be {@link ThrottledFuture}, method call will throw otherwise! *

    + * Whole body of method with {@code void} or {@link Void} return types will be considered as task to be executed later. + * In case of {@link Future} return type, only task in returned {@link ThrottledFuture} is throttled, + * meaning that actual body of the method will be executed every call. + *

    * Note that returned future can be canceled *

    + * Method may also return already canceled or fulfilled future; in that case, the result is returned immediately. + *

    * Example implementation: *

    
      *  {@code @}Throttle(value = "{#paramObj, #anotherParam}")
    @@ -47,8 +60,6 @@
          * The Spring-EL expression
          * returning a List of Objects or a String which will be used to construct the unique identifier
          * for this throttled instance.
    -     * 

    - * In the expression, you have available method parameters. */ @NotNull String value() default ""; @@ -74,9 +85,4 @@ * Blank string disables any group processing. */ @NotNull String group() default ""; - - /** - * Whether the returned future may be from older call. - */ - boolean returnCached() default true; } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index eae28560a..c83446b54 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -35,7 +35,6 @@ import java.util.Arrays; import java.util.Collection; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.NavigableMap; @@ -67,6 +66,7 @@ public class ThrottleAspect implements LongRunningTaskRegister { /** * group, identifier -> future + * * @implSpec Synchronize in the field definition order before modification */ private final Map> throttledFutures; @@ -78,6 +78,7 @@ public class ThrottleAspect implements LongRunningTaskRegister { /** * group, identifier -> future + * * @implSpec Synchronize in the field definition order before modification */ private final NavigableMap> scheduledFutures; @@ -141,6 +142,84 @@ private static StandardEvaluationContext makeDefaultContext() { return standardEvaluationContext; } + /** + * @return future or null + * @throws TermItException when the target method throws + * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or {@link Future} + * @implNote Around advice configured in {@code spring-aop.xml} + */ + public synchronized @Nullable Object throttleMethodCall(@NotNull ProceedingJoinPoint joinPoint, + @NotNull Throttle throttleAnnotation) throws Throwable { + + // if the current thread is already executing a throttled code, we want to skip further throttling + if (throttledThreads.contains(Thread.currentThread().getId())) { + // proceed with method execution + final Object result = joinPoint.proceed(); + if (result instanceof ThrottledFuture throttledFuture) { + // directly run throttled future + throttledFuture.run(); + return throttledFuture; + } + return result; + } + + final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + + // construct the throttle instance key + final Identifier identifier = makeIdentifier(joinPoint, throttleAnnotation); + LOG.trace("Throttling task with key '{}'", identifier); + + synchronized (scheduledFutures) { + if (!identifier.getGroup().isBlank()) { + // check if there is a task with lower group + // and if so, cancel this task in favor of the lower group + final Map.Entry> lowerEntry = scheduledFutures.lowerEntry(identifier); + if (lowerEntry != null) { + final Future lowerFuture = lowerEntry.getValue(); + boolean hasGroupPrefix = identifier.hasGroupPrefix(lowerEntry.getKey().getGroup()); + if (hasGroupPrefix && !lowerFuture.isDone() && !lowerFuture.isCancelled()) { + LOG.trace("Throttling canceled due to scheduled lower task '{}'", lowerEntry.getKey()); + return ThrottledFuture.canceled(); + } + } + + cancelWithHigherGroup(identifier); + } + } + + // if there is a scheduled task and this throttled instance was executed in the last THROTTLE_THRESHOLD + // cancel the scheduled task + // -> the execution is further delayed + Future oldFuture = scheduledFutures.get(identifier); + boolean throttleExpired = isThresholdExpired(identifier); + if (oldFuture != null && !throttleExpired) { + oldFuture.cancel(false); + } + + // acquire a throttled future from a map, or make a new one + ThrottledFuture oldThrottledFuture = throttledFutures.getOrDefault(identifier, new ThrottledFuture<>()); + + Pair> pair = getFutureTask(joinPoint, identifier, oldThrottledFuture); + Runnable task = pair.getFirst(); + ThrottledFuture future = pair.getSecond(); + // update the throttled future in the map + synchronized (throttledFutures) { + throttledFutures.put(identifier, future); + } + + Object result = resultVoidOrFuture(signature, future); + + if (future.isCompleted() || future.isRunning()) { + return result; + } + + if (oldFuture == null || oldFuture.isDone() || oldFuture.isCancelled()) { + schedule(identifier, task, throttleExpired); + } + + return result; + } + /** * Maps parameter names from the method signature to their values from {@link JoinPoint#getArgs()} * @@ -223,7 +302,8 @@ private Pair> getFutureTask(@NotNull Proceedin return new Pair<>(toSchedule, throttledFuture); } - private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Identifier identifier, boolean withTransaction) { + private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Identifier identifier, + boolean withTransaction) { final Supplier securityContext = SecurityContextHolder.getDeferredContext(); return () -> { if (throttledFuture.isCancelled() || throttledFuture.isDone()) { @@ -233,14 +313,11 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id final Long threadId = Thread.currentThread().getId(); throttledThreads.add(threadId); - LOG.atTrace() - .addArgument(() -> { - synchronized (scheduledFutures) { - return scheduledFutures.values().stream().filter(f -> !f.isDone() && !f.isCancelled()) - .count(); - } - }).addArgument(identifier) - .log("Running throttled task [{} left] '{}'"); + LOG.atTrace().addArgument(() -> { + synchronized (scheduledFutures) { + return scheduledFutures.values().stream().filter(f -> !f.isDone() && !f.isCancelled()).count() - 1; + } + }).addArgument(identifier).log("Running throttled task [{} left] '{}'"); // restore the security context SecurityContextHolder.setContext(securityContext.get()); @@ -280,7 +357,8 @@ private void clearOldFutures() { synchronized (throttledFutures) { synchronized (lastRun) { synchronized (scheduledFutures) { - Stream.of(throttledFutures.keySet().stream(), scheduledFutures.keySet().stream(), lastRun.keySet().stream()) + Stream.of(throttledFutures.keySet().stream(), scheduledFutures.keySet().stream(), lastRun.keySet() + .stream()) .flatMap(s -> s).distinct().toList() // ensures safe modification of maps .forEach(identifier -> { if (isThresholdExpired(identifier)) { @@ -307,82 +385,7 @@ private void clearOldFutures() { * true when the task had never run */ private boolean isThresholdExpired(Identifier identifier) { - return lastRun.getOrDefault(identifier, Instant.EPOCH) - .isBefore(Instant.now(clock).minus(THROTTLE_THRESHOLD)); - } - - /** - * @return future or null - * @throws TermItException when the target method throws - * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or {@link Future} - * @implNote Around advice configured in {@code spring-aop.xml} - */ - public synchronized @Nullable Object throttleMethodCall(@NotNull ProceedingJoinPoint joinPoint, - @NotNull Throttle throttleAnnotation) throws Throwable { - - // if the current thread is already executing a throttled code, we want to skip further throttling - if (throttledThreads.contains(Thread.currentThread().getId())) { - // proceed with method execution - final Object result = joinPoint.proceed(); - if (result instanceof ThrottledFuture throttledFuture) { - // directly run throttled future - throttledFuture.run(); - return throttledFuture; - } - return result; - } - - final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - - // construct the throttle instance key - final Identifier identifier = makeIdentifier(joinPoint, throttleAnnotation); - LOG.trace("Throttling task with key '{}'", identifier); - - synchronized (scheduledFutures) { - if (!identifier.getGroup().isBlank()) { - // check if there is a task with lower group - // and if so, cancel this task in favor of the lower group - final Map.Entry> lowerEntry = scheduledFutures.lowerEntry(identifier); - if (lowerEntry != null) { - final Future lowerFuture = lowerEntry.getValue(); - boolean hasGroupPrefix = identifier.hasGroupPrefix(lowerEntry.getKey().getGroup()); - if (hasGroupPrefix && !lowerFuture.isDone() && !lowerFuture.isCancelled()) { - LOG.trace("Throttling canceled due to scheduled lower task '{}'", lowerEntry.getKey()); - return ThrottledFuture.canceled(); - } - } - - cancelWithHigherGroup(identifier); - } - } - - // if there is a scheduled task and this throttled instance was executed in the last THROTTLE_THRESHOLD - // cancel the scheduled task - // -> the execution is further delayed - Future oldFuture = scheduledFutures.get(identifier); - boolean throttleExpired = isThresholdExpired(identifier); - if (oldFuture != null && !throttleExpired) { - oldFuture.cancel(false); - } - - // acquire a throttled future from a map, or make a new one - ThrottledFuture oldThrottledFuture = throttledFutures.getOrDefault(identifier, new ThrottledFuture<>()); - - Pair> pair = getFutureTask(joinPoint, identifier, oldThrottledFuture); - Runnable task = pair.getFirst(); - ThrottledFuture future = pair.getSecond(); - // update the throttled future in the map - synchronized (throttledFutures) { - throttledFutures.put(identifier, future); - } - - Object result = resultVoidOrFuture(signature, future); - - if (oldFuture == null || oldFuture.isDone() || oldFuture.isCancelled()) { - schedule(identifier, task, throttleExpired); - } - - return result; + return lastRun.getOrDefault(identifier, Instant.EPOCH).isBefore(Instant.now(clock).minus(THROTTLE_THRESHOLD)); } @SuppressWarnings("unchecked") diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index 7d80df2c5..1e3d33d54 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -136,6 +136,9 @@ public boolean isRunning() { return completingSince != null; } + /** + * @return true if the future is done or canceled, false otherwise + */ @Override public boolean isCompleted() { return isDone() && isCancelled(); diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemoverTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemoverTest.java deleted file mode 100644 index 7a0757b45..000000000 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemoverTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package cz.cvut.kbss.termit.persistence.dao.util; - -import cz.cvut.kbss.termit.persistence.dao.BaseDaoTestRunner; - -class ScheduledContextRemoverTest extends BaseDaoTestRunner { -// -// @Autowired -// private EntityManager em; -// -// @Autowired -// private ScheduledContextRemover sut; -// -// @Test -// void runContextRemovalDropsContextsRegisteredForRemoval() { -// final Set graphs = generateGraphs(); -// graphs.forEach(sut::scheduleForRemoval); -// -// sut.runContextRemoval(); -// graphs.forEach(g -> assertFalse( -// em.createNativeQuery("ASK { ?g ?y ?z . }", Boolean.class).setParameter("g", g).getSingleResult())); -// } -// -// private Set generateGraphs() { -// final Set result = new HashSet<>(); -// transactional(() -> { -// for (int i = 0; i < 5; i++) { -// final URI graphUri = Generator.generateUri(); -// em.createNativeQuery("INSERT DATA { GRAPH ?g { ?g a ?type } }", Void.class) -// .setParameter("g", graphUri) -// .setParameter("type", URI.create(RDFS.RESOURCE)) -// .executeUpdate(); -// result.add(graphUri); -// } -// }); -// return result; -// } -} diff --git a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java index 6d5dc2a0a..e140492e7 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java @@ -158,7 +158,7 @@ void getContentReturnsContentOfRequestedFile() throws Exception { final java.io.File content = createTemporaryHtmlFile(); when(resourceServiceMock.getContent(eq(file), any(ResourceRetrievalSpecification.class))) .thenReturn(new TypeAwareFileSystemResource(content, MediaType.TEXT_HTML_VALUE)); - final MvcResult mvcResult = performAsync(get(PATH + "/" + FILE_NAME + "/content")) + final MvcResult mvcResult = mockMvc.perform(get(PATH + "/" + FILE_NAME + "/content")) .andExpect(status().isOk()).andReturn(); final String resultContent = mvcResult.getResponse().getContentAsString(); assertEquals(HTML_CONTENT, resultContent); @@ -378,7 +378,7 @@ void getContentSupportsReturningContentAsAttachment() throws Exception { final java.io.File content = createTemporaryHtmlFile(); when(resourceServiceMock.getContent(eq(file), any(ResourceRetrievalSpecification.class))) .thenReturn(new TypeAwareFileSystemResource(content, MediaType.TEXT_HTML_VALUE)); - final MvcResult mvcResult = performAsync( + final MvcResult mvcResult = mockMvc.perform( get(PATH + "/" + FILE_NAME + "/content").param("attachment", Boolean.toString(true))) .andExpect(status().isOk()).andReturn(); assertThat(mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION), containsString("attachment")); @@ -419,7 +419,7 @@ void getContentWithTimestampReturnsContentOfRequestedFileAtSpecifiedTimestamp() final Instant at = Utils.timestamp().truncatedTo(ChronoUnit.SECONDS); when(resourceServiceMock.getContent(eq(file), any(ResourceRetrievalSpecification.class))) .thenReturn(new TypeAwareFileSystemResource(content, MediaType.TEXT_HTML_VALUE)); - final MvcResult mvcResult = performAsync(get(PATH + "/" + FILE_NAME + "/content") + final MvcResult mvcResult = mockMvc.perform(get(PATH + "/" + FILE_NAME + "/content") .queryParam("at", Constants.TIMESTAMP_FORMATTER.format(at))) .andExpect(status().isOk()).andReturn(); final String resultContent = mvcResult.getResponse().getContentAsString(); @@ -452,7 +452,7 @@ void getContentWithoutUnconfirmedOccurrencesReturnsContentOfRequestedFileAtWitho final java.io.File content = createTemporaryHtmlFile(); when(resourceServiceMock.getContent(eq(file), any(ResourceRetrievalSpecification.class))) .thenReturn(new TypeAwareFileSystemResource(content, MediaType.TEXT_HTML_VALUE)); - final MvcResult mvcResult = performAsync(get(PATH + "/" + FILE_NAME + "/content") + final MvcResult mvcResult = mockMvc.perform(get(PATH + "/" + FILE_NAME + "/content") .queryParam("withoutUnconfirmedOccurrences", Boolean.toString(true))) .andExpect(status().isOk()).andReturn(); final String resultContent = mvcResult.getResponse().getContentAsString(); diff --git a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java index d64716588..6aa45dcc8 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java @@ -149,7 +149,7 @@ void termsExistCheckReturnOkIfTermLabelExistsInVocabulary() throws Exception { when(idResolverMock.resolveIdentifier(namespace, VOCABULARY_NAME)).thenReturn(vocabularyUri); when(termServiceMock.getVocabularyReference(vocabularyUri)).thenReturn(vocabulary); when(termServiceMock.existsInVocabulary(any(), any(), any())).thenReturn(true); - performAsync( + mockMvc.perform( head(PATH + VOCABULARY_NAME + "/terms") .param(QueryParams.NAMESPACE, namespace) .param("prefLabel", name) @@ -167,7 +167,7 @@ void termsExistCheckReturn404IfTermLabelDoesNotExistInVocabulary() throws Except when(idResolverMock.resolveIdentifier(namespace, VOCABULARY_NAME)).thenReturn(vocabularyUri); when(termServiceMock.getVocabularyReference(vocabularyUri)).thenReturn(vocabulary); when(termServiceMock.existsInVocabulary(any(), any(), any())).thenReturn(false); - performAsync( + mockMvc.perform( head(PATH + VOCABULARY_NAME + "/terms") .param(QueryParams.NAMESPACE, namespace) .param("prefLabel", name) @@ -408,7 +408,7 @@ void getAllRootsLoadsRootsFromCorrectPage() throws Exception { final List terms = termsToDtos(Generator.generateTermsWithIds(5)); when(termServiceMock.findVocabularyRequired(vocabulary.getUri())).thenReturn(vocabulary); when(termServiceMock.findAllRoots(eq(vocabulary), any(Pageable.class), anyCollection())).thenReturn(terms); - performAsync(get(PATH + VOCABULARY_NAME + "/terms/roots").param(PAGE, "5").param(PAGE_SIZE, "100")) + mockMvc.perform(get(PATH + VOCABULARY_NAME + "/terms/roots").param(PAGE, "5").param(PAGE_SIZE, "100")) .andExpect(status().isOk()); final ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); @@ -422,7 +422,7 @@ void getAllRootsCreatesDefaultPageRequestWhenPagingInfoIsNotSpecified() throws E final List terms = termsToDtos(Generator.generateTermsWithIds(5)); when(termServiceMock.findVocabularyRequired(vocabulary.getUri())).thenReturn(vocabulary); when(termServiceMock.findAllRoots(eq(vocabulary), any(Pageable.class), anyCollection())).thenReturn(terms); - performAsync(get(PATH + VOCABULARY_NAME + "/terms/roots")).andExpect(status().isOk()); + mockMvc.perform(get(PATH + VOCABULARY_NAME + "/terms/roots")).andExpect(status().isOk()); final ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); verify(termServiceMock).findAllRoots(eq(vocabulary), captor.capture(), anyCollection()); @@ -701,7 +701,7 @@ void getAllRootsWithPageSpecAndIncludeImportsGetsRootTermsIncludingImportedTerms .thenReturn(URI.create(VOCABULARY_URI)); when(termServiceMock.findVocabularyRequired(vocabulary.getUri())).thenReturn(vocabulary); - performAsync( + mockMvc.perform( get(PATH + VOCABULARY_NAME + "/terms/roots").param("includeImported", Boolean.TRUE.toString())) .andExpect(status().isOk()); verify(termServiceMock).findAllRootsIncludingImported(vocabulary, DEFAULT_PAGE_SPEC, Collections.emptyList()); @@ -852,7 +852,7 @@ void getAllRootsPassesProvidedIdentifiersOfTermsToIncludeToService() throws Exce when(termServiceMock.findVocabularyRequired(vocabulary.getUri())).thenReturn(vocabulary); when(termServiceMock.findAllRoots(eq(vocabulary), any(Pageable.class), anyCollection())).thenReturn(terms); final List toInclude = Arrays.asList(Generator.generateUri(), Generator.generateUri()); - performAsync(get(PATH + VOCABULARY_NAME + "/terms/roots").param("includeTerms", + mockMvc.perform(get(PATH + VOCABULARY_NAME + "/terms/roots").param("includeTerms", toInclude.stream().map(URI::toString) .toArray(String[]::new))) .andExpect(status().isOk()); @@ -1046,7 +1046,7 @@ void checkTermsRetrievesNumberOfTermsInVocabularyWithSpecifiedIdentifier() throw final Integer termCount = Generator.randomInt(0, 200); when(termServiceMock.getTermCount(vocabulary)).thenReturn(termCount); - final MvcResult mvcResult = performAsync(head(PATH + VOCABULARY_NAME + "/terms")).andExpect(status().isOk()) + final MvcResult mvcResult = mockMvc.perform(head(PATH + VOCABULARY_NAME + "/terms")).andExpect(status().isOk()) .andReturn(); final String countHeader = mvcResult.getResponse().getHeader(Constants.X_TOTAL_COUNT_HEADER); assertNotNull(countHeader); diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java index 769764ff2..7a60fa74b 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java @@ -4,6 +4,9 @@ import java.lang.reflect.Method; +/** + * Allows construction of {@link MethodSignature} for testing purposes + */ public class MockedMethodSignature implements MethodSignature { private Class returnType; @@ -23,6 +26,10 @@ public Class getReturnType() { return returnType; } + public void setReturnType(Class returnType) { + this.returnType = returnType; + } + @Override public Method getMethod() { return null; @@ -72,16 +79,4 @@ public Class getDeclaringType() { public String getDeclaringTypeName() { return ""; } - - public void setReturnType(Class returnType) { - this.returnType = returnType; - } - - public void setParameterTypes(Class[] parameterTypes) { - this.parameterTypes = parameterTypes; - } - - public void setParameterNames(String[] parameterNames) { - this.parameterNames = parameterNames; - } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java index 5a9924c6c..43d25e967 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java @@ -4,14 +4,15 @@ import java.lang.annotation.Annotation; +/** + * Implementation of annotation interface allowing instancing for testing purposes + */ public class MockedThrottle implements Throttle { private String value; private String group; - private boolean returnCached = false; - public MockedThrottle(String value, String group) { this.value = value; this.group = group; @@ -27,11 +28,6 @@ public MockedThrottle(String value, String group) { return group; } - @Override - public boolean returnCached() { - return returnCached; - } - @Override public Class annotationType() { return Throttle.class; @@ -44,8 +40,4 @@ public void setValue(String value) { public void setGroup(String group) { this.group = group; } - - public void setReturnCached(boolean returnCached) { - this.returnCached = returnCached; - } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedFutureTask.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java similarity index 53% rename from src/test/java/cz/cvut/kbss/termit/util/throttle/MockedFutureTask.java rename to src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java index dc5678713..ad1148079 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedFutureTask.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java @@ -8,31 +8,23 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -public class MockedFutureTask extends FutureTask implements ScheduledFuture { +public class ScheduledFutureTask extends FutureTask implements ScheduledFuture { - private final Callable callable; - - public MockedFutureTask(@NotNull Callable callable) { + public ScheduledFutureTask(@NotNull Callable callable) { super(callable); - this.callable = callable; } - public MockedFutureTask(@NotNull Runnable runnable, T result) { + public ScheduledFutureTask(@NotNull Runnable runnable, T result) { super(runnable, result); - this.callable = null; - } - - public Callable getCallable() { - return callable; } @Override public long getDelay(@NotNull TimeUnit unit) { - return 0; + throw new UnsupportedOperationException("Not implemented"); } @Override public int compareTo(@NotNull Delayed o) { - return 0; + throw new UnsupportedOperationException("Not implemented"); } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java index 010670a84..af6c8a913 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java @@ -44,7 +44,7 @@ void beforeEach() { reset(taskScheduler); when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).then(invocation -> { Runnable task = invocation.getArgument(0, Runnable.class); - return new MockedFutureTask<>(task, null); + return new ScheduledFutureTask<>(task, null); }); } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index 87b7685f3..570ad5ab2 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -1,13 +1,16 @@ package cz.cvut.kbss.termit.util.throttle; import com.vladsch.flexmark.util.collection.OrderedMap; +import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.ThrottleAspectException; import org.aspectj.lang.ProceedingJoinPoint; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.support.TaskUtils; import org.springframework.test.annotation.DirtiesContext; import java.time.Clock; @@ -15,12 +18,13 @@ import java.time.Instant; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.AbstractMap; import java.util.Map; import java.util.NavigableMap; import java.util.Optional; +import java.util.Random; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; @@ -124,10 +128,13 @@ void beforeEach() throws Throwable { taskSchedulerTasks = new OrderedMap<>(); taskScheduler = mock(TaskScheduler.class); + when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).then(invocation -> { - taskSchedulerTasks.put(invocation.getArgument(0, Runnable.class), invocation.getArgument(1, Instant.class)); + final Runnable decorated = TaskUtils.decorateTaskWithErrorHandler(invocation.getArgument(0, Runnable.class), null, false); + final ScheduledFutureTask task = new ScheduledFutureTask<>(Executors.callable(decorated)); + taskSchedulerTasks.put(task, invocation.getArgument(1, Instant.class)); System.out.println("Scheduled task at " + invocation.getArgument(1, Instant.class)); - return new MockedFutureTask<>(Executors.callable(invocation.getArgument(0, Runnable.class))); + return task; }); throttledFutures = new OrderedMap<>(); @@ -151,9 +158,37 @@ Instant getInstant() { } void addSecond() { - clock = Clock.offset(clock, Duration.ofSeconds(1)); + clock = Clock.fixed(clock.instant().plusSeconds(1), ZoneId.of("UTC")); + } + + void skipThreshold() { + clock = Clock.fixed(clock.instant().plus(THROTTLE_THRESHOLD), ZoneId.of("UTC")); + } + + void executeScheduledTasks() { + taskSchedulerTasks.forEach((runnable, instant) -> runnable.run()); + taskSchedulerTasks.clear(); + } + + @Test + void firstCallAfterThresholdIsScheduledImmediately() throws Throwable { + sut.throttleMethodCall(joinPointA, throttleA); + executeScheduledTasks(); + + skipThreshold(); + addSecond(); + + final Instant expectedTime = getInstant(); + sut.throttleMethodCall(joinPointA, throttleA); + + assertEquals(1, taskSchedulerTasks.size()); + assertEquals(expectedTime, taskSchedulerTasks.getValue(0)); } + /** + * Calling the annotated method three times + * will execute it only once with the newest data. + */ @Test void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { final String[] params = new String[]{"param1", "param2", "param3", "param4", "param5", "param6"}; @@ -163,11 +198,10 @@ void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { final Supplier methodTask = () -> "method result"; final Supplier anotherMethodResult = () -> "another method result"; - final ThrottledFuture methodFuture = new ThrottledFuture<>(); - methodFuture.update(methodTask); + final ThrottledFuture methodFuture = ThrottledFuture.of(methodTask); // for each method call, make new future with "another method task" - doAnswer(invocation -> new ThrottledFuture().update(anotherMethodResult)).when(joinPointA).proceed(); + doAnswer(invocation -> ThrottledFuture.of(anotherMethodResult)).when(joinPointA).proceed(); final Instant firstCall = getInstant(); // simulate first call @@ -209,6 +243,10 @@ void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { assertEquals(methodTask.get(), future.get()); } + /** + * When method is called in the throttle interval + * call are merged and method will be executed only once. + */ @Test void callsInThrottleIntervalAreMerged() throws Throwable { final String[] params = new String[]{"param1", "param2", "param3", "param4", "param5", "param6"}; @@ -238,7 +276,7 @@ void callsInThrottleIntervalAreMerged() throws Throwable { @Test void schedulesNewFutureWhenTheOldOneIsCompleted() throws Throwable { doReturn(Future.class).when(signatureA).getReturnType(); - when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(()->"result")); + when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(() -> "result")); Future firstFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); addSecond(); @@ -247,8 +285,7 @@ void schedulesNewFutureWhenTheOldOneIsCompleted() throws Throwable { assertFalse(firstFuture.isCancelled()); assertEquals(1, taskSchedulerTasks.size()); - taskSchedulerTasks.forEach((runnable, instant) -> runnable.run()); - taskSchedulerTasks.clear(); + executeScheduledTasks(); assertTrue(firstFuture.isDone()); assertFalse(firstFuture.isCancelled()); @@ -259,15 +296,56 @@ void schedulesNewFutureWhenTheOldOneIsCompleted() throws Throwable { assertFalse(secondFuture.isDone()); assertFalse(secondFuture.isCancelled()); + assertEquals(1, scheduledFutures.size()); assertEquals(1, taskSchedulerTasks.size()); - taskSchedulerTasks.forEach((runnable, instant) -> runnable.run()); - taskSchedulerTasks.clear(); + executeScheduledTasks(); assertTrue(secondFuture.isDone()); assertFalse(secondFuture.isCancelled()); assertNotEquals(firstFuture, secondFuture); + } + + /** + * Ensures that calling the annotated method even outside the threshold + * merges calls when no future was resolved. + */ + @SuppressWarnings("unchecked") + @Test + void callsAreMergedWhenCalledOutsideTheThresholdButNoFutureExecutedYet() throws Throwable { + doReturn(Future.class).when(signatureA).getReturnType(); + final String firstResult = "first result"; + final String secondResult = "second result"; + when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(() -> firstResult)); + + Future firstFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); + skipThreshold(); + skipThreshold(); + + when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(() -> secondResult)); + Future secondFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); + + assertNotNull(firstFuture); + assertFalse(firstFuture.isDone()); + assertFalse(firstFuture.isCancelled()); + + assertNotNull(secondFuture); + assertFalse(secondFuture.isDone()); + assertFalse(secondFuture.isCancelled()); + + assertEquals(firstFuture, secondFuture); assertEquals(1, scheduledFutures.size()); + assertEquals(1, taskSchedulerTasks.size()); + executeScheduledTasks(); + + assertTrue(firstFuture.isDone()); + assertFalse(firstFuture.isCancelled()); + // future resolved with the newest call + assertEquals(secondResult, firstFuture.get()); + + assertTrue(secondFuture.isDone()); + assertFalse(secondFuture.isCancelled()); + assertEquals(secondResult, secondFuture.get()); } @Test @@ -408,18 +486,28 @@ void exceptionPropagatedWhenJoinPointProceedThrows() throws Throwable { sut.throttleMethodCall(joinPointA, throttleA); - assertThrows(RuntimeException.class, () -> taskSchedulerTasks.forEach((r, i) -> r.run())); + assertDoesNotThrow(() -> taskSchedulerTasks.forEach((r, i) -> r.run())); } @Test - void exceptionPropagatedFutureTask() throws Throwable { + void exceptionPropagatedFromFutureTask() throws Throwable { + final String exceptionMessage = "termit exception"; when(joinPointA.proceed()).then(invocation -> new ThrottledFuture<>().update(() -> { - throw new RuntimeException(); + throw new TermItException(exceptionMessage); })); signatureA.setReturnType(Future.class); sut.throttleMethodCall(joinPointA, throttleA); - assertThrows(RuntimeException.class, () -> taskSchedulerTasks.forEach((r, i) -> r.run())); + assertEquals(1, taskSchedulerTasks.size()); + assertEquals(1, scheduledFutures.size()); + Runnable scheduled = taskSchedulerTasks.getKey(0); + Future future = scheduledFutures.firstEntry().getValue(); + + assertNotNull(scheduled); + assertNotNull(future); + assertDoesNotThrow(scheduled::run); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + assertEquals(exceptionMessage, e.getCause().getMessage()); } } From 5edbb6a2d5ed1484a51d5574beb060c428750455 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 27 Aug 2024 12:58:41 +0200 Subject: [PATCH 088/150] validation caching --- .../event/VocabularyContentModified.java | 5 +- .../termit/persistence/dao/VocabularyDao.java | 40 +++++++- .../validation/ResultCachingValidator.java | 93 +++++++++++++++++-- .../persistence/validation/Validator.java | 26 ++++-- .../VocabularyContentValidator.java | 5 +- .../termit/rest/VocabularyController.java | 3 - .../termit/service/business/TermService.java | 2 + .../service/business/VocabularyService.java | 2 - .../service/document/AnnotationGenerator.java | 8 +- .../kbss/termit/service/jmx/AppAdminBean.java | 2 - .../VocabularyRepositoryService.java | 11 +-- .../kbss/termit/util/{throttle => }/Pair.java | 2 +- .../termit/util/throttle/CachableFuture.java | 22 +++++ .../termit/util/throttle/ThrottleAspect.java | 25 ++--- .../util/throttle/ThrottleGroupProvider.java | 4 + .../termit/util/throttle/ThrottledFuture.java | 88 ++++++++++++++---- .../ResultCachingValidatorTest.java | 76 ++++++++++----- .../persistence/validation/ValidatorTest.java | 9 +- .../termit/rest/ResourceControllerTest.java | 2 +- .../termit/rest/VocabularyControllerTest.java | 7 +- .../termit/service/jmx/AppAdminBeanTest.java | 9 -- .../util/throttle/TestFutureRunner.java | 24 +++++ .../util/throttle/ThrottleAspectTest.java | 17 +++- 23 files changed, 369 insertions(+), 113 deletions(-) rename src/main/java/cz/cvut/kbss/termit/util/{throttle => }/Pair.java (97%) create mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java create mode 100644 src/test/java/cz/cvut/kbss/termit/util/throttle/TestFutureRunner.java diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java index 8eed780fd..e3c18117d 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java @@ -17,9 +17,11 @@ */ package cz.cvut.kbss.termit.event; +import org.jetbrains.annotations.NotNull; import org.springframework.context.ApplicationEvent; import java.net.URI; +import java.util.Objects; /** * Represents an event of modification of the content of a vocabulary. @@ -30,8 +32,9 @@ public class VocabularyContentModified extends ApplicationEvent { private final URI vocabularyIri; - public VocabularyContentModified(Object source, URI vocabularyIri) { + public VocabularyContentModified(Object source, @NotNull URI vocabularyIri) { super(source); + Objects.requireNonNull(vocabularyIri); this.vocabularyIri = vocabularyIri; } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index 90fd87bf7..816d4d1a0 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -45,6 +45,8 @@ import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; +import cz.cvut.kbss.termit.util.throttle.CachableFuture; +import cz.cvut.kbss.termit.util.throttle.Throttle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -58,11 +60,14 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; import static cz.cvut.kbss.termit.util.Constants.DEFAULT_PAGE_SIZE; import static cz.cvut.kbss.termit.util.Constants.SKOS_CONCEPT_MATCH_RELATIONSHIPS; @@ -160,6 +165,38 @@ public Collection getTransitivelyImportedVocabularies(URI vocabulary) { } } + /** + * @return a map from term URI to vocabulary URI including all terms from all imported vocabularies + */ + public Map getTermToVocabularyMap(Set vocabularies) { + Objects.requireNonNull(vocabularies); + if (vocabularies.isEmpty()) return Map.of(); + try { + return ((Stream) em.createNativeQuery(""" + SELECT DISTINCT ?subject ?object WHERE { + ?object a ?vocabulary. + FILTER(?object in (?vocabularies)) + + ?subject ?inVocabulary ?object ; + a ?term . + }""", "RDFStatement") + .setParameter("vocabularies", vocabularies) + .setParameter("vocabulary", typeUri) + .setParameter("term", URI.create(EntityToOwlClassMapper.getOwlClassForEntity(Term.class))) + .setParameter("inVocabulary", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_p_je_pojmem_ze_slovniku)) + .getResultStream()) + .reduce(new HashMap<>(), (map, statement) -> { + map.put(statement.getSubject(), statement.getObject()); + return map; + }, (mapA, mapB) -> { + mapA.putAll(mapB); + return mapA; + }); + } catch (RuntimeException e) { + throw new PersistenceException(e); + } + } + /** * Gets identifiers of vocabularies which directly import the supplied one. * @@ -356,8 +393,9 @@ public void refreshLastModified(RefreshLastModifiedEvent event) { refreshLastModified(); } + @Throttle("{#vocabulary}") @Transactional - public List validateContents(URI vocabulary) { + public CachableFuture> validateContents(URI vocabulary) { final VocabularyContentValidator validator = context.getBean(VocabularyContentValidator.class); final Collection importClosure = getTransitivelyImportedVocabularies(vocabulary); importClosure.add(vocabulary); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index eb757ccfd..69eabcb28 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -17,10 +17,15 @@ */ package cz.cvut.kbss.termit.persistence.validation; +import cz.cvut.kbss.termit.event.EvictCacheEvent; import cz.cvut.kbss.termit.event.VocabularyContentModified; import cz.cvut.kbss.termit.model.validation.ValidationResult; +import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; +import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Lookup; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; @@ -30,6 +35,13 @@ import java.net.URI; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -43,12 +55,74 @@ public class ResultCachingValidator implements VocabularyContentValidator { private static final Logger LOG = LoggerFactory.getLogger(ResultCachingValidator.class); - private final Map, List> validationCache = new ConcurrentHashMap<>(); + /** + * Map of vocabulary IRI to boolean. + * True when cache for the vocabulary is dirty. + */ + private final Map cacheDirtiness = new ConcurrentHashMap<>(); + + private final Map> validationCache = new HashMap<>(); + + private final VocabularyDao vocabularyDao; + + @Autowired + public ResultCachingValidator(VocabularyDao vocabularyDao) { + this.vocabularyDao = vocabularyDao; + } + + /** + * @return true when the cache contents are dirty and should be refreshed; false otherwise. + */ + public boolean isDirty(URI vocabularyIris) { + return cacheDirtiness.getOrDefault(vocabularyIris, true); + } + + private List getCached(Set vocabularyIris) { + synchronized (validationCache) { + return vocabularyIris.stream().flatMap(v -> validationCache.getOrDefault(v, List.of()).stream()).toList(); + } + } @Override - public List validate(Collection vocabularyIris) { - final Set copy = new HashSet<>(vocabularyIris); // Defensive copy - return new ArrayList<>(validationCache.computeIfAbsent(copy, uris -> getValidator().validate(vocabularyIris))); + public @NotNull ThrottledFuture> validate(@NotNull Collection vocabularyIris) { + final Set iris = new HashSet<>(vocabularyIris); + + if (iris.isEmpty()) { + return ThrottledFuture.done(List.of()); + } + + boolean cacheDirty = iris.stream().anyMatch(this::isDirty); + List cached = getCached(iris); + if (!cacheDirty) { + return ThrottledFuture.done(cached); + } + + return ThrottledFuture.of(() -> runValidation(iris)).setCachedResult(cached.isEmpty() ? null : cached); + } + + + private @NotNull List runValidation(@NotNull final Set iris) { + final List results = getValidator().runValidation(iris); + + final Map termToVocabularyMap = vocabularyDao.getTermToVocabularyMap(iris); + + boolean cacheDirty = iris.stream().anyMatch(this::isDirty); + if (!cacheDirty) { + return getCached(iris); + } + + synchronized (validationCache) { + iris.forEach(vocabulary -> { + cacheDirtiness.put(vocabulary, false); + validationCache.computeIfAbsent(vocabulary, k -> new ArrayList<>()).clear(); + }); + results.parallelStream().forEach(result -> { + final URI vocabulary = termToVocabularyMap.get(result.getTermUri()); + validationCache.get(vocabulary).add(result); + }); + } + + return results; } @Lookup @@ -57,8 +131,15 @@ Validator getValidator() { } @EventListener - public void evictCache(VocabularyContentModified event) { - LOG.debug("Vocabulary content modified, evicting validation result cache."); + public void evictVocabularyCache(VocabularyContentModified event) { + LOG.debug("Vocabulary content modified, marking cache as dirty for {}.", event.getVocabularyIri()); + cacheDirtiness.put(event.getVocabularyIri(), true); + } + + @EventListener(EvictCacheEvent.class) + public void evictCache() { + LOG.debug("Validation cache cleared"); + cacheDirtiness.clear(); validationCache.clear(); } } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java index 80775c3b9..74a7a6c63 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java @@ -26,6 +26,7 @@ import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; +import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.apache.jena.rdf.model.Literal; import org.apache.jena.rdf.model.Model; import org.apache.jena.rdf.model.ModelFactory; @@ -36,6 +37,7 @@ import org.eclipse.rdf4j.repository.Repository; import org.eclipse.rdf4j.repository.RepositoryConnection; import org.eclipse.rdf4j.rio.turtle.TurtleWriter; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -80,7 +82,6 @@ public class Validator implements VocabularyContentValidator { private final EntityManager em; private final VocabularyContextMapper vocabularyContextMapper; - private com.github.sgov.server.Validator validator; private Model validationModel; @Autowired @@ -102,8 +103,7 @@ public Validator(EntityManager em, */ private void initValidator(String language) { try { - this.validator = new com.github.sgov.server.Validator(); - this.validationModel = initValidationModel(validator, language); + this.validationModel = initValidationModel(new com.github.sgov.server.Validator(), language); } catch (IOException e) { throw new TermItException("Unable to initialize validator.", e); } @@ -140,25 +140,35 @@ private void loadOverrideRules(Model validationModel, String language) throws IO @Transactional(readOnly = true) @Override - public List validate(final Collection vocabularyIris) { + public @NotNull ThrottledFuture> validate(final @NotNull Collection vocabularyIris) { + if (vocabularyIris.isEmpty()) { + return ThrottledFuture.done(List.of()); + } + + return ThrottledFuture.of(() -> runValidation(vocabularyIris)); + } + + protected synchronized List runValidation(@NotNull Collection vocabularyIris) { LOG.debug("Validating {}", vocabularyIris); try { final Model dataModel = getModelFromRdf4jRepository(vocabularyIris); - org.topbraid.shacl.validation.ValidationReport report = validator.validate(dataModel, validationModel); + // TODO: would be better to cache the validator, but its not thread safe + org.topbraid.shacl.validation.ValidationReport report = new com.github.sgov.server.Validator() + .validate(dataModel, validationModel); LOG.debug("Done."); return report.results().stream() .sorted(new ValidationResultSeverityComparator()).map(result -> { final URI termUri = URI.create(result.getFocusNode().toString()); final URI severity = URI.create(result.getSeverity().getURI()); final URI errorUri = result.getSourceShape().isURIResource() ? - URI.create(result.getSourceShape().getURI()) : null; + URI.create(result.getSourceShape().getURI()) : null; final URI resultPath = result.getPath() != null && result.getPath().isURIResource() ? - URI.create(result.getPath().getURI()) : null; + URI.create(result.getPath().getURI()) : null; final MultilingualString messages = new MultilingualString(result.getMessages().stream() .map(RDFNode::asLiteral) .collect(Collectors.toMap( lit -> lit.getLanguage().isBlank() ? - JsonLd.NONE : lit.getLanguage(), + JsonLd.NONE : lit.getLanguage(), Literal::getLexicalForm))); return new ValidationResult() diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java index 85ce431f0..8ce8159ec 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java @@ -18,6 +18,8 @@ package cz.cvut.kbss.termit.persistence.validation; import cz.cvut.kbss.termit.model.validation.ValidationResult; +import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; +import org.jetbrains.annotations.NotNull; import java.net.URI; import java.util.Collection; @@ -36,5 +38,6 @@ public interface VocabularyContentValidator { * @param vocabularyIris Vocabulary identifiers * @return List of violations of validation rules. Empty list if there are not violations */ - List validate(final Collection vocabularyIris); + @NotNull + ThrottledFuture> validate(@NotNull Collection vocabularyIris); } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 3a462eb95..e21676086 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -49,7 +49,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.scheduling.annotation.Async; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -71,8 +70,6 @@ import java.util.List; import java.util.Optional; import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; /** * Vocabulary management REST API. diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index 5b674825c..9f7f79fe4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -42,6 +42,7 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.Utils; +import cz.cvut.kbss.termit.util.throttle.Throttle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -438,6 +439,7 @@ public void remove(@NonNull Term term) { * @param term Term to analyze * @param vocabularyIri Identifier of the vocabulary used for analysis */ + @Throttle(value = "{#vocabularyIri, #term.getUri()}", group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyTerm(#vocabulary.getUri(), #term.getUri())") @PreAuthorize("@termAuthorizationService.canModify(#term)") public void analyzeTermDefinition(AbstractTerm term, URI vocabularyIri) { Objects.requireNonNull(term); diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index d3f09d045..dc10b6de3 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -46,7 +46,6 @@ import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.throttle.Throttle; -import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,7 +72,6 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.Future; import static cz.cvut.kbss.termit.util.Constants.VOCABULARY_REMOVAL_IGNORED_RELATIONS; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index 0e31d86f2..cf4fef6e4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -29,17 +29,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StopWatch; import java.io.InputStream; -import java.util.List; import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; -import java.util.concurrent.SynchronousQueue; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; /** * Creates annotations (term occurrences) for vocabulary terms. @@ -75,6 +70,7 @@ public AnnotationGenerator(DocumentManager documentManager, TermOccurrenceResolv * @param source Source file of the annotated document */ @Transactional + @Throttle("{source.getUri()}") public void generateAnnotations(InputStream content, File source) { final TermOccurrenceResolver occurrenceResolver = findResolverFor(source); LOG.debug("Resolving annotations of file {}.", source); @@ -153,7 +149,7 @@ private void saveAnnotatedContent(File file, InputStream input) { */ @Transactional @Throttle(value = "{#annotatedTerm.getUri()}") - public void generateAnnotationsSync(InputStream content, AbstractTerm annotatedTerm) { + public void generateAnnotations(InputStream content, AbstractTerm annotatedTerm) { // We assume the content (text analysis output) is HTML-compatible final TermOccurrenceResolver occurrenceResolver = resolvers.htmlTermOccurrenceResolver(); LOG.debug("Resolving annotations of the definition of {}.", annotatedTerm); diff --git a/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java b/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java index ae8019e24..c6095f424 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java +++ b/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java @@ -19,7 +19,6 @@ import cz.cvut.kbss.termit.event.EvictCacheEvent; import cz.cvut.kbss.termit.event.RefreshLastModifiedEvent; -import cz.cvut.kbss.termit.event.VocabularyContentModified; import cz.cvut.kbss.termit.rest.dto.HealthInfo; import cz.cvut.kbss.termit.service.mail.Message; import cz.cvut.kbss.termit.service.mail.Postman; @@ -66,7 +65,6 @@ public void invalidateCaches() { eventPublisher.publishEvent(new EvictCacheEvent(this)); LOG.info("Refreshing last modified timestamps..."); eventPublisher.publishEvent(new RefreshLastModifiedEvent(this)); - eventPublisher.publishEvent(new VocabularyContentModified(this, null)); } @ManagedOperation(description = "Sends test email to the specified address.") diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index a45ab0421..f417b9d2d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -63,6 +63,7 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -319,14 +320,8 @@ private void ensureNoTermRelationsExists(Vocabulary vocabulary) throws AssetRemo } } - public List validateContents(URI vocabulary) { - try { - return vocabularyDao.validateContents(vocabulary); - } finally { - // we can be sure that validator allocated - // quite a large memory amount which can be cleared now - System.gc(); - } + public Optional> validateContents(URI vocabulary) { + return vocabularyDao.validateContents(vocabulary).getCachedResult(); } public Integer getTermCount(Vocabulary vocabulary) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Pair.java b/src/main/java/cz/cvut/kbss/termit/util/Pair.java similarity index 97% rename from src/main/java/cz/cvut/kbss/termit/util/throttle/Pair.java rename to src/main/java/cz/cvut/kbss/termit/util/Pair.java index faaf7dace..ff23400a0 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Pair.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Pair.java @@ -1,4 +1,4 @@ -package cz.cvut.kbss.termit.util.throttle; +package cz.cvut.kbss.termit.util; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java new file mode 100644 index 000000000..97095fd05 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java @@ -0,0 +1,22 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; +import java.util.concurrent.Future; + +public interface CachableFuture extends Future { + + /** + * @return the cached result when available + */ + Optional getCachedResult(); + + /** + * Sets possible cached result + * + * @param cachedResult the result to set, or null to clear the cache + * @return self + */ + CachableFuture setCachedResult(@Nullable final T cachedResult); +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index c83446b54..1b0e63dd7 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -3,6 +3,7 @@ import cz.cvut.kbss.termit.TermItApplication; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.ThrottleAspectException; +import cz.cvut.kbss.termit.util.Pair; import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskRegister; import org.aspectj.lang.JoinPoint; @@ -13,6 +14,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Scope; import org.springframework.core.annotation.Order; @@ -102,7 +104,7 @@ public class ThrottleAspect implements LongRunningTaskRegister { private final @NotNull AtomicReference lastClear; @Autowired - public ThrottleAspect(TaskScheduler taskScheduler, TransactionExecutor transactionExecutor) { + public ThrottleAspect(@Qualifier("threadPoolTaskScheduler") TaskScheduler taskScheduler, TransactionExecutor transactionExecutor) { this.taskScheduler = taskScheduler; this.transactionExecutor = transactionExecutor; throttledFutures = new HashMap<>(); @@ -240,13 +242,6 @@ private static void resolveParameters(Map map, MethodSignature s } } - @SuppressWarnings("unchecked") - private static ThrottledFuture transferTask(@NotNull ThrottledFuture source, - @NotNull ThrottledFuture target) { - // casting the type parameter to Object - return ((ThrottledFuture) source).transfer((ThrottledFuture) target); - } - private EvaluationContext makeContext(JoinPoint joinPoint, Map parameters) { StandardEvaluationContext context = new StandardEvaluationContext(); standardEvaluationContext.applyDelegatesTo(context); @@ -278,12 +273,16 @@ private Pair> getFutureTask(@NotNull Proceedin if (result instanceof ThrottledFuture throttledMethodFuture) { // future acquired by key or a new future supplied, ensuring the same type // ThrottledFuture#updateOther will create a new future when required - throttledFuture = transferTask(throttledMethodFuture, throttledFuture); + if (throttledMethodFuture.isDone()) { + throttledFuture = (ThrottledFuture) throttledMethodFuture; + } else { + throttledFuture = ((ThrottledFuture) throttledMethodFuture).transfer(throttledFuture); + } } else { throw new ThrottleAspectException("Returned value is not a ThrottledFuture"); } } else { - throttledFuture.update(() -> { + throttledFuture = throttledFuture.update(() -> { try { return joinPoint.proceed(); } catch (Throwable e) { @@ -313,11 +312,7 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id final Long threadId = Thread.currentThread().getId(); throttledThreads.add(threadId); - LOG.atTrace().addArgument(() -> { - synchronized (scheduledFutures) { - return scheduledFutures.values().stream().filter(f -> !f.isDone() && !f.isCancelled()).count() - 1; - } - }).addArgument(identifier).log("Running throttled task [{} left] '{}'"); + LOG.trace("Running throttled task [{} left] '{}'", scheduledFutures.size() - 1, identifier); // restore the security context SecurityContextHolder.setContext(securityContext.get()); diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java index 741cb2c94..44ce739f0 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java @@ -18,4 +18,8 @@ public static String getTextAnalysisVocabulariesAll() { public static String getTextAnalysisVocabularyAllTerms(URI vocabulary) { return TEXT_ANALYSIS_VOCABULARIES + "_" + vocabulary; } + + public static String getTextAnalysisVocabularyTerm(URI vocabulary, URI term) { + return TEXT_ANALYSIS_VOCABULARIES + "_" + vocabulary + "_" + term; + } } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index 1e3d33d54..a6a497892 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -6,16 +6,19 @@ import org.jetbrains.annotations.Nullable; import java.time.Instant; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Supplier; -public class ThrottledFuture implements Future, LongRunningTask { +public class ThrottledFuture implements CachableFuture, LongRunningTask { - private final Object lock = new Object(); + private final ReentrantLock lock = new ReentrantLock(); + + private T cachedResult = null; private final CompletableFuture future; @@ -49,12 +52,34 @@ public static ThrottledFuture of(@NotNull final Runnable runnable) { /** * @return already canceled future */ - public static ThrottledFuture canceled() { - ThrottledFuture f = new ThrottledFuture<>(); + public static ThrottledFuture canceled() { + ThrottledFuture f = new ThrottledFuture<>(); f.cancel(true); + assert f.isCancelled(); + return f; + } + + /** + * @return already done future + */ + public static ThrottledFuture done(T result) { + ThrottledFuture f = ThrottledFuture.of(() -> result); + f.run(); + assert f.isDone(); return f; } + @Override + public Optional getCachedResult() { + return Optional.ofNullable(cachedResult); + } + + @Override + public ThrottledFuture setCachedResult(@Nullable final T cachedResult) { + this.cachedResult = cachedResult; + return this; + } + @Override public boolean cancel(boolean mayInterruptIfRunning) { return future.cancel(mayInterruptIfRunning); @@ -70,59 +95,88 @@ public boolean isDone() { return future.isDone(); } + /** + * Does not execute the task, blocks the current thread until some result is available. + * + * @return cached result when available, otherwise awaits future resolution. + */ @Override public T get() throws InterruptedException, ExecutionException { + if (this.cachedResult != null) { + return this.cachedResult; + } return future.get(); } + /** + * Does not execute the task, blocks the current thread until some result is available. + * @return cached result when available, otherwise awaits future resolution. + */ @Override public T get(long timeout, @NotNull TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + if (this.cachedResult != null) { + return this.cachedResult; + } return future.get(timeout, unit); } /** * @param task the new task * @return If the current task is already running, was canceled or already completed, returns a new future for the given task. - * Otherwise, replaces the current task and returns this. + * Otherwise, replaces the current task and returns self. */ protected ThrottledFuture update(Supplier task) { - synchronized (lock) { - if (isRunning() || future.isCancelled() || future.isDone()) { + try { + boolean locked = lock.tryLock(); + if (!locked || isRunning() || isCompleted()) { return ThrottledFuture.of(task); } this.task = task; return this; + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } } /** - * Transfers the task from this object to the specified {@code throttledFuture}. - * If the current task is already running, canceled or completed, this method has no effect. + * Returns future with the task from the specified {@code throttledFuture}. + * If possible, transfers the task from this object to the specified {@code throttledFuture}. * * @param target the future to update * @return target when current future is already being executed, was canceled or completed. * New future when the target is being executed, was canceled or completed. */ protected ThrottledFuture transfer(ThrottledFuture target) { - synchronized (lock) { - if (isRunning() || future.isCancelled() || future.isDone()) { + try { + boolean locked = lock.tryLock(); + if (!locked || isRunning() || isCompleted()) { return target; } ThrottledFuture result = target.update(this.task); this.task = null; return result; + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } } protected void run() { - synchronized (lock) { - if (isRunning() || future.isCancelled() || future.isDone()) { + boolean locked; + do { + locked = lock.tryLock(); + if (isRunning() || isCompleted()) { return; + } else if (!locked) { + Thread.yield(); } - completingSince = Utils.timestamp(); - } + } while (!locked); + completingSince = Utils.timestamp(); if (task != null) { future.complete(task.get()); @@ -141,7 +195,7 @@ public boolean isRunning() { */ @Override public boolean isCompleted() { - return isDone() && isCancelled(); + return isDone() || isCancelled(); } @Override diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java index 8198751a2..4a1dd3dc5 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java @@ -19,7 +19,9 @@ import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.event.VocabularyContentModified; +import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.validation.ValidationResult; +import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -29,9 +31,19 @@ import java.net.URI; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Set; +import static cz.cvut.kbss.termit.util.throttle.TestFutureRunner.runFuture; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.anyCollection; +import static org.mockito.Mockito.anySet; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.mockito.Mockito.anyCollection; import static org.mockito.Mockito.spy; @@ -45,44 +57,62 @@ class ResultCachingValidatorTest { @Mock private Validator validator; + @Mock + private VocabularyDao vocabularyDao; + private ResultCachingValidator sut; + private URI vocabulary; + + private ValidationResult validationResult; + @BeforeEach void setUp() { - this.sut = spy(new ResultCachingValidator()); + this.sut = spy(new ResultCachingValidator(vocabularyDao)); when(sut.getValidator()).thenReturn(validator); + + vocabulary = Generator.generateUri(); + Term term = Generator.generateTermWithId(vocabulary); + validationResult = new ValidationResult() + .setTermUri(term.getUri()); + + when(vocabularyDao.getTermToVocabularyMap(anySet())).thenReturn(Map.of(term.getUri(), vocabulary)); } @Test - void invokesInternalValidatorWhenNoResultsAreCached() { - final List results = Collections.singletonList(new ValidationResult()); - when(validator.validate(anyCollection())).thenReturn(results); - final Set vocabularies = Collections.singleton(Generator.generateUri()); - final List result = sut.validate(vocabularies); + void invokesInternalValidatorWhenNoResultsAreCached() throws Exception { + final List results = Collections.singletonList(validationResult); + when(validator.runValidation(anyCollection())).thenReturn(results); + final Set vocabularies = Collections.singleton(vocabulary); + final List result = runFuture(sut.validate(vocabularies)); assertEquals(results, result); - verify(validator).validate(vocabularies); + verify(validator).runValidation(vocabularies); } @Test - void returnsCachedResultsWhenArgumentsMatch() { - final List results = Collections.singletonList(new ValidationResult()); - when(validator.validate(anyCollection())).thenReturn(results); - final Set vocabularies = Collections.singleton(Generator.generateUri()); - final List resultOne = sut.validate(vocabularies); - final List resultTwo = sut.validate(vocabularies); + void returnsCachedResultsWhenArgumentsMatch() throws Exception { + final List results = Collections.singletonList(validationResult); + when(validator.runValidation(anyCollection())).thenReturn(results); + final Set vocabularies = Collections.singleton(vocabulary); + final List resultOne = runFuture(sut.validate(vocabularies)); + verify(validator).runValidation(vocabularies); + final List resultTwo = runFuture(sut.validate(vocabularies)); assertEquals(resultOne, resultTwo); - verify(validator).validate(vocabularies); + assertSame(results, resultOne); + verifyNoMoreInteractions(validator); } @Test - void evictCacheClearsCachedValidationResults() { - final List results = Collections.singletonList(new ValidationResult()); - when(validator.validate(anyCollection())).thenReturn(results); - final Set vocabularies = Collections.singleton(Generator.generateUri()); - final List resultOne = sut.validate(vocabularies); - sut.evictCache(new VocabularyContentModified(this, null)); - final List resultTwo = sut.validate(vocabularies); - verify(validator, times(2)).validate(vocabularies); - assertNotSame(resultOne, resultTwo); + void evictCacheClearsCachedValidationResults() throws Exception { + final List results = Collections.singletonList(validationResult); + when(validator.runValidation(anyCollection())).thenReturn(results); + final Set vocabularies = Collections.singleton(vocabulary); + final List resultOne = runFuture(sut.validate(vocabularies)); + verify(validator).runValidation(vocabularies); + sut.evictVocabularyCache(new VocabularyContentModified(this, vocabulary)); + final List resultTwo = runFuture(sut.validate(vocabularies)); + verify(validator, times(2)).runValidation(vocabularies); + assertEquals(resultOne, resultTwo); + assertSame(results, resultOne); } } diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java index 092e68556..2a566c69b 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java @@ -20,6 +20,7 @@ import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.User; import cz.cvut.kbss.termit.model.Vocabulary; @@ -36,6 +37,7 @@ import java.util.Collections; import java.util.List; +import static cz.cvut.kbss.termit.util.throttle.TestFutureRunner.runFuture; import static org.junit.jupiter.api.Assertions.assertTrue; class ValidatorTest extends BaseDaoTestRunner { @@ -64,7 +66,12 @@ void validateUsesOverrideRulesToAllowI18n() { final Vocabulary vocabulary = generateVocabulary(); transactional(() -> { final Validator sut = new Validator(em, vocabularyContextMapper, config); - final List result = sut.validate(Collections.singleton(vocabulary.getUri())); + final List result; + try { + result = runFuture(sut.validate(Collections.singleton(vocabulary.getUri()))); + } catch (Exception e) { + throw new TermItException(e); + } assertTrue(result.stream().noneMatch( vr -> vr.getMessage().get("en").contains("The term does not have a preferred label in Czech"))); assertTrue(result.stream().noneMatch( diff --git a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java index e140492e7..27b9f8393 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java @@ -184,7 +184,7 @@ void saveContentSavesContentViaServiceAndReturnsNoContentStatus() throws Excepti final MockMultipartFile upload = new MockMultipartFile("file", file.getLabel(), MediaType.TEXT_HTML_VALUE, Files.readAllBytes(attachment.toPath()) ); - performAsync(multipart(PATH + "/" + FILE_NAME + "/content").file(upload) + mockMvc.perform(multipart(PATH + "/" + FILE_NAME + "/content").file(upload) .with(req -> { req.setMethod(HttpMethod.PUT.toString()); return req; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index 034cdc60a..75fb8f5ba 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -55,7 +55,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.test.web.servlet.MvcResult; import java.io.File; @@ -86,13 +85,11 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ExtendWith(MockitoExtension.class) @@ -484,7 +481,7 @@ void createSnapshotCreatesSnapshotOfVocabularyWithSpecifiedIdentification() thro final Vocabulary vocabulary = generateVocabularyAndInitReferenceResolution(); final Snapshot snapshot = Generator.generateSnapshot(vocabulary); when(serviceMock.createSnapshot(any())).thenReturn(snapshot); - performAsync(post(PATH + "/" + FRAGMENT + "/versions")) + mockMvc.perform(post(PATH + "/" + FRAGMENT + "/versions")) .andExpect(status().isCreated()); verify(serviceMock).createSnapshot(vocabulary); } @@ -494,7 +491,7 @@ void createSnapshotReturnsLocationHeaderWithSnapshotApiPath() throws Exception { final Vocabulary vocabulary = generateVocabularyAndInitReferenceResolution(); final Snapshot snapshot = Generator.generateSnapshot(vocabulary); when(serviceMock.createSnapshot(any())).thenReturn(snapshot); - final MvcResult mvcResult = performAsync(post(PATH + "/" + FRAGMENT + "/versions")) + final MvcResult mvcResult = mockMvc.perform(post(PATH + "/" + FRAGMENT + "/versions")) .andExpect(status().isCreated()) .andReturn(); verifyLocationEquals(PATH + "/" + IdentifierResolver.extractIdentifierFragment(snapshot.getUri()), mvcResult); diff --git a/src/test/java/cz/cvut/kbss/termit/service/jmx/AppAdminBeanTest.java b/src/test/java/cz/cvut/kbss/termit/service/jmx/AppAdminBeanTest.java index e10e552e3..479d67a6b 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/jmx/AppAdminBeanTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/jmx/AppAdminBeanTest.java @@ -19,7 +19,6 @@ import cz.cvut.kbss.termit.event.EvictCacheEvent; import cz.cvut.kbss.termit.event.RefreshLastModifiedEvent; -import cz.cvut.kbss.termit.event.VocabularyContentModified; import cz.cvut.kbss.termit.util.Configuration; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -62,12 +61,4 @@ void invalidateCachesPublishesRefreshLastModifiedEvent() { verify(eventPublisherMock, atLeastOnce()).publishEvent(captor.capture()); assertTrue(captor.getAllValues().stream().anyMatch(RefreshLastModifiedEvent.class::isInstance)); } - - @Test - void invalidateCachesPublishesVocabularyContentModifiedEventToForceEvictionOfVocabularyContentBasedCaches() { - sut.invalidateCaches(); - final ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); - verify(eventPublisherMock, atLeastOnce()).publishEvent(captor.capture()); - assertTrue(captor.getAllValues().stream().anyMatch(VocabularyContentModified.class::isInstance)); - } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/TestFutureRunner.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/TestFutureRunner.java new file mode 100644 index 000000000..b715c32c8 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/TestFutureRunner.java @@ -0,0 +1,24 @@ +package cz.cvut.kbss.termit.util.throttle; + +import java.util.concurrent.ExecutionException; + +public class TestFutureRunner { + + private TestFutureRunner() { + throw new AssertionError(); + } + + /** + * Executes the task inside the future and returns its result. + * + * @implNote Note that this method is intended only for testing purposes. + */ + public static T runFuture(ThrottledFuture future) { + future.run(); + try { + return future.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index 570ad5ab2..5a99e1a82 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -5,7 +5,6 @@ import cz.cvut.kbss.termit.exception.ThrottleAspectException; import org.aspectj.lang.ProceedingJoinPoint; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -14,14 +13,12 @@ import org.springframework.test.annotation.DirtiesContext; import java.time.Clock; -import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.Map; import java.util.NavigableMap; import java.util.Optional; -import java.util.Random; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @@ -510,4 +507,18 @@ void exceptionPropagatedFromFutureTask() throws Throwable { ExecutionException e = assertThrows(ExecutionException.class, future::get); assertEquals(exceptionMessage, e.getCause().getMessage()); } + + @Test + void resolvedFutureFromMethodIsReturnedWithoutSchedule() throws Throwable { + signatureA.setReturnType(Future.class); + final String result = "result of the method"; + when(joinPointA.proceed()).then(invocation -> ThrottledFuture.done(result)); + + Future future = (Future) sut.throttleMethodCall(joinPointA, throttleA); + + assertNotNull(future); + assertTrue(future.isDone()); + assertFalse(future.isCancelled()); + assertEquals(result, future.get()); + } } From 99f029425fb31b974186bb7206d2663c68fb536a Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 3 Sep 2024 12:44:52 +0200 Subject: [PATCH 089/150] fix tests --- .../termit/util/throttle/ThrottleAspectTestContextConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java index 764bc0cc5..17c077a20 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java @@ -19,7 +19,7 @@ public class ThrottleAspectTestContextConfig { @Bean - public ThreadPoolTaskScheduler taskScheduler() { + public ThreadPoolTaskScheduler threadPoolTaskScheduler() { return Mockito.mock(ThreadPoolTaskScheduler.class, RETURNS_SMART_NULLS); } From 5ec261f7ad81921dd38586822f80783ae795056d Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 12:44:13 +0200 Subject: [PATCH 090/150] suppress AsyncRequestNotUsableException from logging --- .../rest/handler/RestExceptionHandler.java | 8 ++++- .../cvut/kbss/termit/util/ExceptionUtils.java | 32 +++++++++++++++++++ .../handler/WebSocketExceptionHandler.java | 8 ++++- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index d83ab552d..530de0969 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -50,8 +50,11 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import static cz.cvut.kbss.termit.util.ExceptionUtils.isCausedBy; + /** * Exception handlers for REST controllers. *

    @@ -82,7 +85,10 @@ private static void logException(Throwable ex, HttpServletRequest request) { } private static void logException(String message, Throwable ex) { - LOG.error(message, ex); + // prevents from logging exceptions caused be broken connection with a client + if (!isCausedBy(ex, AsyncRequestNotUsableException.class)) { + LOG.error(message, ex); + } } private static ErrorInfo errorInfo(HttpServletRequest request, Throwable e) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java new file mode 100644 index 000000000..ad9ada966 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java @@ -0,0 +1,32 @@ +package cz.cvut.kbss.termit.util; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashSet; +import java.util.Set; + +public class ExceptionUtils { + private ExceptionUtils() { + throw new AssertionError(); + } + + /** + * Resolves all nested causes of the {@code throwable} and returns true if any is matching the {@code cause} + */ + public static boolean isCausedBy(@Nullable final Throwable throwable, @NotNull final Class cause) { + @Nullable Throwable t = throwable; + final Set visited = new HashSet<>(); + while (t != null) { + if(visited.add(t)) { + if (cause.isInstance(t)){ + return true; + } + t = t.getCause(); + continue; + } + break; + } + return false; + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index 83895d6cc..bbaec2b69 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -35,8 +35,11 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import static cz.cvut.kbss.termit.util.ExceptionUtils.isCausedBy; + /** * @implSpec Should reflect {@link cz.cvut.kbss.termit.rest.handler.RestExceptionHandler} */ @@ -71,7 +74,10 @@ private static void logException(Throwable ex, Message message) { } private static void logException(String message, Throwable ex) { - LOG.error(message, ex); + // prevents from logging exceptions caused be broken connection with a client + if (!isCausedBy(ex, AsyncRequestNotUsableException.class)) { + LOG.error(message, ex); + } } private static ErrorInfo errorInfo(Message message, Throwable e) { From 498fd8b4917e1d4aa3b01dbec24668bf0ff45617 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 13:26:56 +0200 Subject: [PATCH 091/150] merge --- .../service/business/VocabularyService.java | 8 +++--- .../VocabularyRepositoryService.java | 5 ++-- .../termit/util/throttle/CachableFuture.java | 25 +++++++++++++++++++ .../termit/util/throttle/ThrottledFuture.java | 4 +-- .../termit/websocket/ResultWithHeaders.java | 3 ++- .../websocket/VocabularySocketController.java | 11 ++++++-- ...bSocketMessageWithHeadersValueHandler.java | 5 ++-- .../VocabularySocketControllerTest.java | 5 ++-- .../WebSocketExceptionHandlerTest.java | 14 +++++------ 9 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index dc10b6de3..6a555ed3f 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -45,7 +45,9 @@ import cz.cvut.kbss.termit.util.TypeAwareClasspathResource; import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.TypeAwareResource; +import cz.cvut.kbss.termit.util.throttle.CachableFuture; import cz.cvut.kbss.termit.util.throttle.Throttle; +import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -72,6 +74,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.Future; import static cz.cvut.kbss.termit.util.Constants.VOCABULARY_REMOVAL_IGNORED_RELATIONS; @@ -345,9 +348,8 @@ public void remove(Vocabulary asset) { * * @param vocabulary Vocabulary to validate */ - @Throttle("{#vocabulary}") - public Future> validateContents(URI vocabulary) { - return ThrottledFuture.of(() -> repositoryService.validateContents(vocabulary)); + public CachableFuture> validateContents(URI vocabulary) { + return repositoryService.validateContents(vocabulary); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index f417b9d2d..e3c18fd90 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -41,6 +41,7 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Utils; +import cz.cvut.kbss.termit.util.throttle.CachableFuture; import cz.cvut.kbss.termit.workspace.EditableVocabularies; import jakarta.validation.Validator; import org.apache.tika.Tika; @@ -320,8 +321,8 @@ private void ensureNoTermRelationsExists(Vocabulary vocabulary) throws AssetRemo } } - public Optional> validateContents(URI vocabulary) { - return vocabularyDao.validateContents(vocabulary).getCachedResult(); + public CachableFuture> validateContents(URI vocabulary) { + return vocabularyDao.validateContents(vocabulary); } public Integer getTermCount(Vocabulary vocabulary) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java index 97095fd05..5225dac65 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java @@ -1,10 +1,17 @@ package cz.cvut.kbss.termit.util.throttle; +import cz.cvut.kbss.termit.exception.TermItException; import org.jetbrains.annotations.Nullable; import java.util.Optional; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; +import java.util.function.Consumer; +/** + * A future which can provide a cached result before its completion. + * @see Future + */ public interface CachableFuture extends Future { /** @@ -19,4 +26,22 @@ public interface CachableFuture extends Future { * @return self */ CachableFuture setCachedResult(@Nullable final T cachedResult); + + /** + * @return the future result if it is available, cached result otherwise. + */ + default Optional getNow() { + try { + if (isDone() && !isCancelled()) { + return Optional.of(get()); + } + } catch (ExecutionException e) { + throw new TermItException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TermItException(e); + } + + return getCachedResult(); + } } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index a6a497892..0df141c05 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -102,7 +102,7 @@ public boolean isDone() { */ @Override public T get() throws InterruptedException, ExecutionException { - if (this.cachedResult != null) { + if (!isDone() && this.cachedResult != null) { return this.cachedResult; } return future.get(); @@ -115,7 +115,7 @@ public T get() throws InterruptedException, ExecutionException { @Override public T get(long timeout, @NotNull TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { - if (this.cachedResult != null) { + if (!isDone() && this.cachedResult != null) { return this.cachedResult; } return future.get(timeout, unit); diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java b/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java index 8d74eeb72..55718666d 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java @@ -10,6 +10,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Objects; /** * Wrapper carrying a result from WebSocket controller @@ -52,7 +53,7 @@ private ResultWithHeadersBuilder(T payload) { */ public ResultWithHeadersBuilder withHeaders(@NotNull Map headers) { this.headers = new HashMap<>(); - headers.forEach((key, value) -> this.headers.put(key, value.toString())); + headers.forEach((key, value) -> this.headers.put(key, Objects.toString(value))); this.headers = Collections.unmodifiableMap(this.headers); return this; } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index 7e49b35e0..3a11f0438 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -8,6 +8,7 @@ import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; +import cz.cvut.kbss.termit.util.throttle.CachableFuture; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -36,6 +37,7 @@ protected VocabularySocketController(IdentifierResolver idResolver, Configuratio /** * Validates the terms in a vocabulary with the specified identifier. + * Immediately responds with a result from the cache, if available. */ @MessageMapping("/{localName}/validate") public ResultWithHeaders> validateVocabulary(@DestinationVariable String localName, @@ -43,7 +45,12 @@ public ResultWithHeaders> validateVocabulary(@Destination required = false) Optional namespace) { final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); final Vocabulary vocabulary = vocabularyService.getReference(identifier); - return result(vocabularyService.validateContents(vocabulary)).withHeaders(Map.of("vocabulary", identifier)) - .sendToUser("/vocabularies/validation"); + + final CachableFuture> future = vocabularyService.validateContents(vocabulary.getUri()); + + return future.getNow() + .map(validationResults -> result(validationResults).withHeaders(Map.of("vocabulary", identifier, "cached", !future.isDone())) + .sendToUser("/vocabularies/validation")) + .orElse(null); } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java index 5494294f2..a0d9ecb95 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java @@ -25,8 +25,9 @@ public boolean supportsReturnType(MethodParameter returnType) { } @Override - public void handleReturnValue(Object returnValue, @NotNull MethodParameter returnType, @NotNull Message message) - throws Exception { + public void handleReturnValue(Object returnValue, @NotNull MethodParameter returnType, + @NotNull Message message) { + if (returnValue == null) return; if (returnValue instanceof ResultWithHeaders resultWithHeaders) { final StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message); resultWithHeaders.headers().forEach(headerAccessor::setNativeHeader); diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java index 65507b909..db4483ad3 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java @@ -7,6 +7,7 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.VocabularyService; +import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; @@ -71,7 +72,7 @@ void validateVocabularyValidatesContents() { this.serverInboundChannel.send(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); - verify(vocabularyService).validateContents(vocabulary); + verify(vocabularyService).validateContents(vocabulary.getUri()); } @Test @@ -86,7 +87,7 @@ void validateVocabularyReturnsValidationResults() { .setSeverity(Generator.generateUri()) .setIssueCauseUri(Generator.generateUri()); final List validationResults = List.of(validationResult); - when(vocabularyService.validateContents(vocabulary)).thenReturn(validationResults); + when(vocabularyService.validateContents(vocabulary.getUri())).thenReturn(ThrottledFuture.done(validationResults)); this.serverInboundChannel.send(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java index 52df68045..01f072bbf 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java @@ -48,12 +48,12 @@ void sendMessage() { this.serverInboundChannel.send(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); } - @Test - void handlerIsCalledForPersistenceException() { - final PersistenceException e = new PersistenceException(new Exception("mocked exception")); - when(controller.validateVocabulary(any(), any())).thenThrow(e); - sendMessage(); - verify(sut).persistenceException(notNull(), eq(e)); - } +// @Test // TODO +// void handlerIsCalledForPersistenceException() { +// final PersistenceException e = new PersistenceException(new Exception("mocked exception")); +// when(controller.validateVocabulary(any(), any())).thenThrow(e); +// sendMessage(); +// verify(sut).persistenceException(notNull(), eq(e)); +// } } From 23293a851b9ecace7840c5c1f9eb255174a997ab Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 13:35:27 +0200 Subject: [PATCH 092/150] optimize imports --- .../persistence/validation/ResultCachingValidator.java | 6 ------ .../cz/cvut/kbss/termit/rest/VocabularyController.java | 1 - src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java | 1 - .../kbss/termit/service/business/VocabularyService.java | 4 ---- .../termit/service/document/TermOccurrenceResolver.java | 1 - .../kbss/termit/service/document/TermOccurrenceSaver.java | 1 - .../document/html/TextPositionSelectorGenerator.java | 2 -- .../service/repository/VocabularyRepositoryService.java | 1 - .../cz/cvut/kbss/termit/util/throttle/CachableFuture.java | 1 - .../termit/environment/config/TestRestSecurityConfig.java | 1 - .../validation/ResultCachingValidatorTest.java | 6 ------ .../cvut/kbss/termit/rest/BaseControllerTestRunner.java | 1 - .../cvut/kbss/termit/rest/VocabularyControllerTest.java | 2 -- .../termit/service/repository/VocabularyServiceTest.java | 1 - .../termit/websocket/WebSocketExceptionHandlerTest.java | 8 -------- 15 files changed, 37 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 69eabcb28..7e61fcda6 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -40,12 +40,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @Component("cachingValidator") diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index e21676086..25ffe093e 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -27,7 +27,6 @@ import cz.cvut.kbss.termit.model.acl.AccessControlRecord; import cz.cvut.kbss.termit.model.acl.AccessLevel; import cz.cvut.kbss.termit.model.changetracking.AbstractChangeRecord; -import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.rest.doc.ApiDocConstants; import cz.cvut.kbss.termit.rest.util.RestUtils; import cz.cvut.kbss.termit.security.SecurityConstants; diff --git a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java index 99d8e52ae..e10d16462 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java @@ -27,7 +27,6 @@ import cz.cvut.kbss.termit.util.Utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 6a555ed3f..a0a2d3ba9 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -41,13 +41,11 @@ import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; -import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.TypeAwareClasspathResource; import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.throttle.CachableFuture; import cz.cvut.kbss.termit.util.throttle.Throttle; -import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,7 +62,6 @@ import java.io.File; import java.net.URI; -import java.net.URISyntaxException; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; @@ -74,7 +71,6 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.Future; import static cz.cvut.kbss.termit.util.Constants.VOCABULARY_REMOVAL_IGNORED_RELATIONS; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java index 566fdec7b..0139a5d30 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java @@ -28,7 +28,6 @@ import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import java.io.InputStream; -import java.io.OutputStream; import java.net.URI; import java.util.Collections; import java.util.List; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java index 16a0fc4c4..99d208715 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java @@ -5,7 +5,6 @@ import java.util.List; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicBoolean; /** diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java index 013f0cb93..30808f52b 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java @@ -18,8 +18,6 @@ package cz.cvut.kbss.termit.service.document.html; import cz.cvut.kbss.termit.model.selector.TextPositionSelector; -import org.apache.logging.log4j.util.BiConsumer; -import org.apache.logging.log4j.util.TriConsumer; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index e3c18fd90..de67c2c18 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -64,7 +64,6 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java index 5225dac65..26b0b29ac 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java @@ -6,7 +6,6 @@ import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -import java.util.function.Consumer; /** * A future which can provide a cached result before its completion. diff --git a/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java b/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java index 15a2df9de..6ac7ddba1 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java @@ -34,7 +34,6 @@ import org.springframework.security.authentication.AuthenticationProvider; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** * This configuration class is necessary when testing security of REST controllers (e.g., {@link diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java index 4a1dd3dc5..3b5cd30db 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java @@ -44,12 +44,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.mockito.Mockito.anyCollection; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ResultCachingValidatorTest { diff --git a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java index 5cf068f58..12ef9adc4 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java @@ -28,7 +28,6 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.accept.ContentNegotiationManager; -import org.springframework.web.method.support.HandlerMethodReturnValueHandler; import static cz.cvut.kbss.termit.environment.Environment.createDefaultMessageConverter; import static cz.cvut.kbss.termit.environment.Environment.createJsonLdMessageConverter; diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index 75fb8f5ba..dcf02e40b 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -37,11 +37,9 @@ import cz.cvut.kbss.termit.rest.handler.ErrorInfo; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.VocabularyService; -import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Constants.QueryParams; -import cz.cvut.kbss.termit.environment.util.MockedFuture; import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java index f08ecb92c..26512aec2 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java @@ -73,7 +73,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyBoolean; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java index 01f072bbf..1271c0943 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java @@ -2,10 +2,8 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; -import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.messaging.simp.stomp.StompCommand; @@ -15,12 +13,6 @@ import java.util.HashMap; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.notNull; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - class WebSocketExceptionHandlerTest extends BaseWebSocketControllerTestRunner { @SpyBean From 347f0007347642ae201a573456a3658fce490475 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 4 Sep 2024 16:36:01 +0200 Subject: [PATCH 093/150] validation cache --- .../event/VocabularyValidationFinished.java | 62 ++++++++++++++ .../model/validation/ValidationResult.java | 3 +- .../termit/persistence/dao/VocabularyDao.java | 38 +-------- .../validation/ResultCachingValidator.java | 81 +++++++++---------- .../persistence/validation/Validator.java | 14 +++- .../VocabularyContentValidator.java | 5 +- .../service/business/VocabularyService.java | 2 +- .../VocabularyRepositoryService.java | 2 +- .../websocket/VocabularySocketController.java | 13 ++- .../ResultCachingValidatorTest.java | 18 ++--- .../persistence/validation/ValidatorTest.java | 46 ++++++++++- .../BaseWebSocketControllerTestRunner.java | 25 ++++-- .../BaseWebSocketIntegrationTestRunner.java | 21 +++-- .../IntegrationWebSocketSecurityTest.java | 14 +++- .../VocabularySocketControllerTest.java | 1 + 15 files changed, 227 insertions(+), 118 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinished.java diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinished.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinished.java new file mode 100644 index 000000000..055a9a174 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinished.java @@ -0,0 +1,62 @@ +package cz.cvut.kbss.termit.event; + +import cz.cvut.kbss.termit.model.validation.ValidationResult; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; +import org.springframework.context.ApplicationEvent; + +import java.net.URI; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Indicates that validation for a set of vocabularies was finished. + */ +public class VocabularyValidationFinished extends ApplicationEvent { + + /** + * Vocabulary closure of {@link #originVocabularyIri}. + * IRIs of vocabularies that are imported by {@link #originVocabularyIri} and were part of the validation. + */ + @NotNull + @Unmodifiable + private final Collection vocabularyIris; + + @NotNull + @Unmodifiable + private final Collection validationResults; + + /** + * IRI of the vocabulary on which the validation was triggered. + */ + @NotNull + private final URI originVocabularyIri; + + /** + * @param source the source of the event + * @param originVocabularyIri Vocabulary closure of {@link #originVocabularyIri}. + * @param vocabularyIris IRI of the vocabulary on which the validation was triggered. + * @param validationResults results of the validation + */ + public VocabularyValidationFinished(@NotNull Object source, @NotNull URI originVocabularyIri, + @NotNull Collection vocabularyIris, + @NotNull List validationResults) { + super(source); + this.vocabularyIris = Collections.unmodifiableCollection(vocabularyIris); + this.validationResults = Collections.unmodifiableCollection(validationResults); + this.originVocabularyIri = originVocabularyIri; + } + + public @NotNull Collection getVocabularyIris() { + return vocabularyIris; + } + + public @NotNull Collection getValidationResults() { + return validationResults; + } + + public @NotNull URI getOriginVocabularyIri() { + return originVocabularyIri; + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java b/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java index 331bc461b..ab4f30f9d 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java +++ b/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java @@ -26,12 +26,13 @@ import cz.cvut.kbss.termit.model.Term; import org.topbraid.shacl.vocabulary.SH; +import java.io.Serializable; import java.net.URI; import java.util.Objects; @NonEntity @OWLClass(iri = SH.BASE_URI + "ValidationResult") -public class ValidationResult { +public class ValidationResult implements Serializable { @Id(generated = true) private URI id; diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index 816d4d1a0..0ac254b16 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -149,7 +149,7 @@ public Vocabulary getReference(URI id) { /** * Gets identifiers of all vocabularies imported by the specified vocabulary, including transitively imported ones. * - * @param entity Base vocabulary, whose imports should be retrieved + * @param vocabulary Base vocabulary, whose imports should be retrieved * @return Collection of (transitively) imported vocabularies */ public Collection getTransitivelyImportedVocabularies(URI vocabulary) { @@ -165,38 +165,6 @@ public Collection getTransitivelyImportedVocabularies(URI vocabulary) { } } - /** - * @return a map from term URI to vocabulary URI including all terms from all imported vocabularies - */ - public Map getTermToVocabularyMap(Set vocabularies) { - Objects.requireNonNull(vocabularies); - if (vocabularies.isEmpty()) return Map.of(); - try { - return ((Stream) em.createNativeQuery(""" - SELECT DISTINCT ?subject ?object WHERE { - ?object a ?vocabulary. - FILTER(?object in (?vocabularies)) - - ?subject ?inVocabulary ?object ; - a ?term . - }""", "RDFStatement") - .setParameter("vocabularies", vocabularies) - .setParameter("vocabulary", typeUri) - .setParameter("term", URI.create(EntityToOwlClassMapper.getOwlClassForEntity(Term.class))) - .setParameter("inVocabulary", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_p_je_pojmem_ze_slovniku)) - .getResultStream()) - .reduce(new HashMap<>(), (map, statement) -> { - map.put(statement.getSubject(), statement.getObject()); - return map; - }, (mapA, mapB) -> { - mapA.putAll(mapB); - return mapA; - }); - } catch (RuntimeException e) { - throw new PersistenceException(e); - } - } - /** * Gets identifiers of vocabularies which directly import the supplied one. * @@ -395,11 +363,11 @@ public void refreshLastModified(RefreshLastModifiedEvent event) { @Throttle("{#vocabulary}") @Transactional - public CachableFuture> validateContents(URI vocabulary) { + public CachableFuture> validateContents(URI vocabulary) { final VocabularyContentValidator validator = context.getBean(VocabularyContentValidator.class); final Collection importClosure = getTransitivelyImportedVocabularies(vocabulary); importClosure.add(vocabulary); - return validator.validate(importClosure); + return validator.validate(vocabulary, importClosure); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 7e61fcda6..0ca49297d 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -20,12 +20,11 @@ import cz.cvut.kbss.termit.event.EvictCacheEvent; import cz.cvut.kbss.termit.event.VocabularyContentModified; import cz.cvut.kbss.termit.model.validation.ValidationResult; -import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Lookup; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; @@ -33,10 +32,9 @@ import org.springframework.stereotype.Component; import java.net.URI; -import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -50,70 +48,53 @@ public class ResultCachingValidator implements VocabularyContentValidator { private static final Logger LOG = LoggerFactory.getLogger(ResultCachingValidator.class); /** - * Map of vocabulary IRI to boolean. - * True when cache for the vocabulary is dirty. + * Map of origin vocabulary IRI to vocabulary iri closure of imported vocabularies. + * When the value is null, then the cache entry is considered dirty. */ - private final Map cacheDirtiness = new ConcurrentHashMap<>(); + private final Map> vocabularyClosure = new ConcurrentHashMap<>(); - private final Map> validationCache = new HashMap<>(); - - private final VocabularyDao vocabularyDao; - - @Autowired - public ResultCachingValidator(VocabularyDao vocabularyDao) { - this.vocabularyDao = vocabularyDao; - } + private final Map> validationCache = new HashMap<>(); /** * @return true when the cache contents are dirty and should be refreshed; false otherwise. */ - public boolean isDirty(URI vocabularyIris) { - return cacheDirtiness.getOrDefault(vocabularyIris, true); + public boolean isNotDirty(@NotNull URI originVocabularyIri) { + return vocabularyClosure.containsKey(originVocabularyIri); } - private List getCached(Set vocabularyIris) { + private List getCached(@NotNull URI originVocabularyIri) { synchronized (validationCache) { - return vocabularyIris.stream().flatMap(v -> validationCache.getOrDefault(v, List.of()).stream()).toList(); + return validationCache.getOrDefault(originVocabularyIri, List.of()); } } @Override - public @NotNull ThrottledFuture> validate(@NotNull Collection vocabularyIris) { - final Set iris = new HashSet<>(vocabularyIris); + public @NotNull ThrottledFuture> validate(@NotNull URI originVocabularyIri, @NotNull Collection vocabularyIris) { + final Set iris = Set.copyOf(vocabularyIris); if (iris.isEmpty()) { return ThrottledFuture.done(List.of()); } - boolean cacheDirty = iris.stream().anyMatch(this::isDirty); - List cached = getCached(iris); - if (!cacheDirty) { + List cached = getCached(originVocabularyIri); + if (isNotDirty(originVocabularyIri)) { return ThrottledFuture.done(cached); } - return ThrottledFuture.of(() -> runValidation(iris)).setCachedResult(cached.isEmpty() ? null : cached); + return ThrottledFuture.of(() -> runValidation(originVocabularyIri, iris)).setCachedResult(cached.isEmpty() ? null : cached); } - private @NotNull List runValidation(@NotNull final Set iris) { - final List results = getValidator().runValidation(iris); - - final Map termToVocabularyMap = vocabularyDao.getTermToVocabularyMap(iris); - - boolean cacheDirty = iris.stream().anyMatch(this::isDirty); - if (!cacheDirty) { - return getCached(iris); + private @NotNull Collection runValidation(@NotNull URI originVocabularyIri, @NotNull final Set iris) { + if (isNotDirty(originVocabularyIri)) { + return getCached(originVocabularyIri); } + final List results = getValidator().runValidation(iris); + synchronized (validationCache) { - iris.forEach(vocabulary -> { - cacheDirtiness.put(vocabulary, false); - validationCache.computeIfAbsent(vocabulary, k -> new ArrayList<>()).clear(); - }); - results.parallelStream().forEach(result -> { - final URI vocabulary = termToVocabularyMap.get(result.getTermUri()); - validationCache.get(vocabulary).add(result); - }); + vocabularyClosure.put(originVocabularyIri, Collections.unmodifiableCollection(iris)); + validationCache.put(originVocabularyIri, Collections.unmodifiableList(results)); } return results; @@ -127,13 +108,25 @@ Validator getValidator() { @EventListener public void evictVocabularyCache(VocabularyContentModified event) { LOG.debug("Vocabulary content modified, marking cache as dirty for {}.", event.getVocabularyIri()); - cacheDirtiness.put(event.getVocabularyIri(), true); + // marked as dirty for specified vocabulary + vocabularyClosure.remove(event.getVocabularyIri()); + // now mark all vocabularies importing modified vocabulary as dirty too + synchronized (validationCache) { + vocabularyClosure.keySet().forEach(originVocabularyIri -> { + final @Nullable Collection closure = vocabularyClosure.get(originVocabularyIri); + if (closure != null && closure.contains(event.getVocabularyIri())) { + vocabularyClosure.remove(originVocabularyIri); + } + }); + } } @EventListener(EvictCacheEvent.class) public void evictCache() { LOG.debug("Validation cache cleared"); - cacheDirtiness.clear(); - validationCache.clear(); + synchronized (validationCache) { + vocabularyClosure.clear(); + validationCache.clear(); + } } } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java index 74a7a6c63..845448240 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java @@ -21,6 +21,7 @@ import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jsonld.JsonLd; +import cz.cvut.kbss.termit.event.VocabularyValidationFinished; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper; @@ -41,6 +42,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.stereotype.Component; @@ -81,15 +83,17 @@ public class Validator implements VocabularyContentValidator { private final EntityManager em; private final VocabularyContextMapper vocabularyContextMapper; + private final ApplicationEventPublisher eventPublisher; private Model validationModel; @Autowired public Validator(EntityManager em, VocabularyContextMapper vocabularyContextMapper, - Configuration config) { + Configuration config, ApplicationEventPublisher eventPublisher) { this.em = em; this.vocabularyContextMapper = vocabularyContextMapper; + this.eventPublisher = eventPublisher; initValidator(config.getPersistence().getLanguage()); } @@ -140,12 +144,16 @@ private void loadOverrideRules(Model validationModel, String language) throws IO @Transactional(readOnly = true) @Override - public @NotNull ThrottledFuture> validate(final @NotNull Collection vocabularyIris) { + public @NotNull ThrottledFuture> validate(final @NotNull URI originVocabularyIri, final @NotNull Collection vocabularyIris) { if (vocabularyIris.isEmpty()) { return ThrottledFuture.done(List.of()); } - return ThrottledFuture.of(() -> runValidation(vocabularyIris)); + return ThrottledFuture.of(() -> { + final List results = runValidation(vocabularyIris); + eventPublisher.publishEvent(new VocabularyValidationFinished(this, originVocabularyIri, vocabularyIris, results)); + return results; + }); } protected synchronized List runValidation(@NotNull Collection vocabularyIris) { diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java index 8ce8159ec..b0b53577a 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java @@ -35,9 +35,10 @@ public interface VocabularyContentValidator { *

    * The vocabularies are validated together, as a single unit. * - * @param vocabularyIris Vocabulary identifiers + * @param originVocabularyIri the origin vocabulary IRI + * @param vocabularyIris Vocabulary identifiers (including {@code originVocabularyIri} * @return List of violations of validation rules. Empty list if there are not violations */ @NotNull - ThrottledFuture> validate(@NotNull Collection vocabularyIris); + ThrottledFuture> validate(@NotNull URI originVocabularyIri, @NotNull Collection vocabularyIris); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index a0a2d3ba9..d92c85e15 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -344,7 +344,7 @@ public void remove(Vocabulary asset) { * * @param vocabulary Vocabulary to validate */ - public CachableFuture> validateContents(URI vocabulary) { + public CachableFuture> validateContents(URI vocabulary) { return repositoryService.validateContents(vocabulary); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index de67c2c18..e8a7fab7b 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -320,7 +320,7 @@ private void ensureNoTermRelationsExists(Vocabulary vocabulary) throws AssetRemo } } - public CachableFuture> validateContents(URI vocabulary) { + public CachableFuture> validateContents(URI vocabulary) { return vocabularyDao.validateContents(vocabulary); } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index 3a11f0438..b43fa355b 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -1,5 +1,6 @@ package cz.cvut.kbss.termit.websocket; +import cz.cvut.kbss.termit.event.VocabularyValidationFinished; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.rest.BaseController; @@ -9,6 +10,7 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.throttle.CachableFuture; +import org.springframework.context.event.EventListener; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -16,6 +18,7 @@ import org.springframework.stereotype.Controller; import java.net.URI; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Optional; @@ -40,17 +43,23 @@ protected VocabularySocketController(IdentifierResolver idResolver, Configuratio * Immediately responds with a result from the cache, if available. */ @MessageMapping("/{localName}/validate") - public ResultWithHeaders> validateVocabulary(@DestinationVariable String localName, + public ResultWithHeaders> validateVocabulary(@DestinationVariable String localName, @Header(name = Constants.QueryParams.NAMESPACE, required = false) Optional namespace) { final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); final Vocabulary vocabulary = vocabularyService.getReference(identifier); - final CachableFuture> future = vocabularyService.validateContents(vocabulary.getUri()); + final CachableFuture> future = vocabularyService.validateContents(vocabulary.getUri()); return future.getNow() .map(validationResults -> result(validationResults).withHeaders(Map.of("vocabulary", identifier, "cached", !future.isDone())) .sendToUser("/vocabularies/validation")) .orElse(null); } + + @EventListener + public ResultWithHeaders> onVocabularyValidationFinished(VocabularyValidationFinished event) { + return result(event.getValidationResults()).withHeaders(Map.of("vocabulary", event.getOriginVocabularyIri(), "cached", false)) + .sendTo("/vocabularies/validation"); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java index 3b5cd30db..101653309 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java @@ -29,6 +29,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.net.URI; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -51,9 +52,6 @@ class ResultCachingValidatorTest { @Mock private Validator validator; - @Mock - private VocabularyDao vocabularyDao; - private ResultCachingValidator sut; private URI vocabulary; @@ -62,15 +60,13 @@ class ResultCachingValidatorTest { @BeforeEach void setUp() { - this.sut = spy(new ResultCachingValidator(vocabularyDao)); + this.sut = spy(new ResultCachingValidator()); when(sut.getValidator()).thenReturn(validator); vocabulary = Generator.generateUri(); Term term = Generator.generateTermWithId(vocabulary); validationResult = new ValidationResult() .setTermUri(term.getUri()); - - when(vocabularyDao.getTermToVocabularyMap(anySet())).thenReturn(Map.of(term.getUri(), vocabulary)); } @Test @@ -78,7 +74,7 @@ void invokesInternalValidatorWhenNoResultsAreCached() throws Exception { final List results = Collections.singletonList(validationResult); when(validator.runValidation(anyCollection())).thenReturn(results); final Set vocabularies = Collections.singleton(vocabulary); - final List result = runFuture(sut.validate(vocabularies)); + final Collection result = runFuture(sut.validate(vocabulary, vocabularies)); assertEquals(results, result); verify(validator).runValidation(vocabularies); } @@ -88,9 +84,9 @@ void returnsCachedResultsWhenArgumentsMatch() throws Exception { final List results = Collections.singletonList(validationResult); when(validator.runValidation(anyCollection())).thenReturn(results); final Set vocabularies = Collections.singleton(vocabulary); - final List resultOne = runFuture(sut.validate(vocabularies)); + final Collection resultOne = runFuture(sut.validate(vocabulary, vocabularies)); verify(validator).runValidation(vocabularies); - final List resultTwo = runFuture(sut.validate(vocabularies)); + final Collection resultTwo = runFuture(sut.validate(vocabulary, vocabularies)); assertEquals(resultOne, resultTwo); assertSame(results, resultOne); verifyNoMoreInteractions(validator); @@ -101,10 +97,10 @@ void evictCacheClearsCachedValidationResults() throws Exception { final List results = Collections.singletonList(validationResult); when(validator.runValidation(anyCollection())).thenReturn(results); final Set vocabularies = Collections.singleton(vocabulary); - final List resultOne = runFuture(sut.validate(vocabularies)); + final Collection resultOne = runFuture(sut.validate(vocabulary, vocabularies)); verify(validator).runValidation(vocabularies); sut.evictVocabularyCache(new VocabularyContentModified(this, vocabulary)); - final List resultTwo = runFuture(sut.validate(vocabularies)); + final Collection resultTwo = runFuture(sut.validate(vocabulary, vocabularies)); verify(validator, times(2)).runValidation(vocabularies); assertEquals(resultOne, resultTwo); assertSame(results, resultOne); diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java index 2a566c69b..7bd25aa3a 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java @@ -20,6 +20,7 @@ import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.event.VocabularyValidationFinished; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.User; @@ -32,13 +33,22 @@ import cz.cvut.kbss.termit.util.Constants; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import java.net.URI; +import java.util.Collection; import java.util.Collections; import java.util.List; import static cz.cvut.kbss.termit.util.throttle.TestFutureRunner.runFuture; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; class ValidatorTest extends BaseDaoTestRunner { @@ -54,6 +64,9 @@ class ValidatorTest extends BaseDaoTestRunner { @Autowired private Configuration config; + @Mock + private ApplicationEventPublisher eventPublisher; + @BeforeEach void setUp() { final User author = Generator.generateUserWithId(); @@ -65,10 +78,10 @@ void setUp() { void validateUsesOverrideRulesToAllowI18n() { final Vocabulary vocabulary = generateVocabulary(); transactional(() -> { - final Validator sut = new Validator(em, vocabularyContextMapper, config); - final List result; + final Validator sut = new Validator(em, vocabularyContextMapper, config, eventPublisher); + final Collection result; try { - result = runFuture(sut.validate(Collections.singleton(vocabulary.getUri()))); + result = runFuture(sut.validate(vocabulary.getUri(), Collections.singleton(vocabulary.getUri()))); } catch (Exception e) { throw new TermItException(e); } @@ -81,6 +94,33 @@ void validateUsesOverrideRulesToAllowI18n() { }); } + /** + * Validation is a heavy and long-running task; validator must publish event signalizing validation end + * allowing other components to react on the result. + */ + @Test + void publishesVocabularyValidationFinishedEventAfterValidation() { + final Vocabulary vocabulary = generateVocabulary(); + transactional(() -> { + final Validator sut = new Validator(em, vocabularyContextMapper, config, eventPublisher); + final Collection iris = Collections.singleton(vocabulary.getUri()); + final Collection result; + try { + result = runFuture(sut.validate(vocabulary.getUri(), iris)); + } catch (Exception e) { + throw new TermItException(e); + } + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ApplicationEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + final ApplicationEvent event = eventCaptor.getValue(); + assertInstanceOf(VocabularyValidationFinished.class, event); + final VocabularyValidationFinished finished = (VocabularyValidationFinished) event; + assertIterableEquals(result, finished.getValidationResults()); + assertIterableEquals(iris, finished.getVocabularyIris()); + }); + } + private Vocabulary generateVocabulary() { final Vocabulary vocabulary = Generator.generateVocabularyWithId(); final Term term = Generator.generateTermWithId(vocabulary.getUri()); diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java index da6684097..4c72f7431 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java @@ -3,8 +3,11 @@ import cz.cvut.kbss.termit.environment.config.TestRestSecurityConfig; import cz.cvut.kbss.termit.environment.config.TestWebSocketConfig; import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; +import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; import cz.cvut.kbss.termit.websocket.util.CachingChannelInterceptor; import jakarta.annotation.PostConstruct; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @@ -14,6 +17,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.messaging.Message; import org.springframework.messaging.support.AbstractSubscribableChannel; import org.springframework.test.annotation.DirtiesContext; @@ -26,18 +30,25 @@ import java.util.UUID; import static cz.cvut.kbss.termit.websocket.util.ReturnValueCollectingSimpMessagingTemplate.MESSAGE_IDENTIFIER_HEADER; +import static org.mockito.Mockito.verifyNoMoreInteractions; @ActiveProfiles("test") @ExtendWith(SpringExtension.class) @ExtendWith(MockitoExtension.class) @EnableConfigurationProperties({Configuration.class}) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @ContextConfiguration(classes = {TestRestSecurityConfig.class, TestWebSocketConfig.class}, initializers = {ConfigDataApplicationContextInitializer.class}) public abstract class BaseWebSocketControllerTestRunner { private static final Logger LOG = LoggerFactory.getLogger(BaseWebSocketControllerTestRunner.class); + @SpyBean + protected WebSocketExceptionHandler webSocketExceptionHandler; + + @SpyBean + protected StompExceptionHandler stompExceptionHandler; + /** * Simulated messages from client to server */ @@ -68,8 +79,8 @@ public abstract class BaseWebSocketControllerTestRunner { protected CachingChannelInterceptor brokerChannelInterceptor; - @PostConstruct - protected void runnerPostConstruct() { + @BeforeEach + protected void runnerBeforeEach() { this.brokerChannelInterceptor = new CachingChannelInterceptor(); this.serverOutboundChannelInterceptor = new CachingChannelInterceptor(); @@ -77,11 +88,9 @@ protected void runnerPostConstruct() { this.serverOutboundChannel.addInterceptor(this.serverOutboundChannelInterceptor); } - @BeforeEach - protected void runnerBeforeEach() { - this.serverOutboundChannelInterceptor.reset(); - this.brokerChannelInterceptor.reset(); - this.returnedValuesMap.clear(); + @AfterEach + protected void runnerAfterEach() { + verifyNoMoreInteractions(webSocketExceptionHandler, stompExceptionHandler); } /** diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java index ce30493d2..13e91ed7f 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java @@ -13,7 +13,10 @@ import cz.cvut.kbss.termit.security.model.TermItUserDetails; import cz.cvut.kbss.termit.service.security.TermItUserDetailsService; import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; +import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @@ -47,6 +50,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verifyNoMoreInteractions; @ActiveProfiles("test") @EnableSpringConfigured @@ -61,12 +65,18 @@ initializers = {ConfigDataApplicationContextInitializer.class}) @ComponentScan( {"cz.cvut.kbss.termit.security", "cz.cvut.kbss.termit.websocket", "cz.cvut.kbss.termit.websocket.handler"}) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public abstract class BaseWebSocketIntegrationTestRunner { protected Logger LOG = LoggerFactory.getLogger(this.getClass()); + @SpyBean + protected WebSocketExceptionHandler webSocketExceptionHandler; + + @SpyBean + protected StompExceptionHandler stompExceptionHandler; + protected WebSocketStompClient stompClient; @Value("ws://localhost:${local.server.port}/ws") @@ -80,10 +90,6 @@ public abstract class BaseWebSocketIntegrationTestRunner { protected TermItUserDetails userDetails; - protected Future connect(StompSessionHandlerAdapter sessionHandler) { - return stompClient.connectAsync(url, sessionHandler); - } - protected String generateToken() { return jwtUtils.generateToken(userDetails.getUser(), userDetails.getAuthorities()); } @@ -96,6 +102,11 @@ void runnerSetup() { doReturn(userDetails).when(userDetailsService).loadUserByUsername(userDetails.getUsername()); } + @AfterEach + protected void runnerAfterEach() { + verifyNoMoreInteractions(webSocketExceptionHandler, stompExceptionHandler); + } + protected class TestWebSocketSessionHandler implements WebSocketHandler { @Override diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java index b3bbcc043..64ad6ee52 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java @@ -17,6 +17,7 @@ import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandler; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketMessage; @@ -37,6 +38,10 @@ import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.verify; class IntegrationWebSocketSecurityTest extends BaseWebSocketIntegrationTestRunner { @@ -49,10 +54,11 @@ class IntegrationWebSocketSecurityTest extends BaseWebSocketIntegrationTestRunne ObjectMapper objectMapper; /** - * @return Stream of argument pairs with StompCommand (CONNECT excluded) and true + false value for each command + * @return Stream of argument pairs with StompCommand (CONNECT & DISCONNECT excluded) and true + false value for each command */ public static Stream stompCommands() { - return Arrays.stream(StompCommand.values()).filter(c -> c != StompCommand.CONNECT).map(Enum::name) + return Arrays.stream(StompCommand.values()).filter(c -> c != StompCommand.CONNECT && c != StompCommand.DISCONNECT) + .map(Enum::name) .flatMap(name -> Stream.of(Arguments.of(name, true), Arguments.of(name, false))); } @@ -83,6 +89,7 @@ void connectionIsClosedOnAnyMessageBeforeConnect(String stompCommand, Boolean wi assertTrue(receivedError.get()); assertFalse(session.isOpen()); assertFalse(receivedReply.get()); + verify(webSocketExceptionHandler).messageDeliveryException(notNull(), notNull()); } WebSocketHandler makeWebSocketHandler(AtomicBoolean receivedReply, AtomicBoolean receivedError) { @@ -127,6 +134,7 @@ void connectWithInvalidAuthorizationIsRejected() throws Throwable { assertTrue(receivedError.get()); assertFalse(session.isOpen()); assertFalse(receivedReply.get()); + verify(webSocketExceptionHandler).messageDeliveryException(notNull(), notNull()); } /** @@ -161,6 +169,8 @@ void connectWithInvalidJwtAuthorizationIsRejected() throws Throwable { assertTrue(receivedError.get()); assertFalse(session.isOpen()); assertFalse(receivedReply.get()); + + verify(webSocketExceptionHandler).messageDeliveryException(notNull(), notNull()); } /** diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java index db4483ad3..cd917a1d4 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java @@ -55,6 +55,7 @@ public void setup() { namespace = vocabulary.getUri().toString().substring(0, vocabulary.getUri().toString().lastIndexOf('/')); when(idResolver.resolveIdentifier(namespace, fragment)).thenReturn(vocabulary.getUri()); when(vocabularyService.getReference(vocabulary.getUri())).thenReturn(vocabulary); + when(vocabularyService.validateContents(vocabulary.getUri())).thenReturn(ThrottledFuture.done(List.of())); messageHeaders = StompHeaderAccessor.create(StompCommand.MESSAGE); messageHeaders.setSessionId("0"); From c81d347947888145c70fe671c6cd70c3d71b38cc Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 5 Sep 2024 10:13:49 +0200 Subject: [PATCH 094/150] [Performance #287] Sending messages to session, validation throttling --- doc/throttle-debounce.png | Bin 7807 -> 8278 bytes .../config/WebSocketMessageBrokerConfig.java | 7 -- .../termit/persistence/dao/VocabularyDao.java | 2 +- .../validation/ResultCachingValidator.java | 39 ++++++--- .../persistence/validation/Validator.java | 4 + .../rest/handler/RestExceptionHandler.java | 16 +--- .../termit/util/throttle/ThrottleAspect.java | 10 +-- .../termit/util/throttle/ThrottledFuture.java | 13 +-- .../websocket/BaseWebSocketController.java | 81 ++++++++++++++++++ .../termit/websocket/ResultWithHeaders.java | 69 --------------- .../websocket/VocabularySocketController.java | 46 ++++++---- .../handler/WebSocketExceptionHandler.java | 15 ++-- ...bSocketMessageWithHeadersValueHandler.java | 47 ---------- 13 files changed, 158 insertions(+), 191 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java delete mode 100644 src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java delete mode 100644 src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java diff --git a/doc/throttle-debounce.png b/doc/throttle-debounce.png index e40d8bb183bf656fdce8a19a0f58469134025511..3b00e7b5ff4bae459bced5cdeb2510a9a92550a9 100644 GIT binary patch delta 7603 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 B?HT|8 diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java index ceefa1273..ab5a2dbbc 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java @@ -4,7 +4,6 @@ import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; -import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; import org.jetbrains.annotations.NotNull; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Configuration; @@ -16,7 +15,6 @@ import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.converter.StringMessageConverter; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; -import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; @@ -94,11 +92,6 @@ public void configureClientInboundChannel(@NotNull ChannelRegistration registrat registration.interceptors(webSocketJwtAuthorizationInterceptor, new SecurityContextChannelInterceptor(), interceptor); } - @Override - public void addReturnValueHandlers(List returnValueHandlers) { - returnValueHandlers.add(new WebSocketMessageWithHeadersValueHandler(simpMessagingTemplate)); - } - @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").setAllowedOrigins(allowedOrigins.split(",")); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index 0ac254b16..84bf345cd 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -47,6 +47,7 @@ import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.throttle.CachableFuture; import cz.cvut.kbss.termit.util.throttle.Throttle; +import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -361,7 +362,6 @@ public void refreshLastModified(RefreshLastModifiedEvent event) { refreshLastModified(); } - @Throttle("{#vocabulary}") @Transactional public CachableFuture> validateContents(URI vocabulary) { final VocabularyContentValidator validator = context.getBean(VocabularyContentValidator.class); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 0ca49297d..11fede874 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -19,7 +19,9 @@ import cz.cvut.kbss.termit.event.EvictCacheEvent; import cz.cvut.kbss.termit.event.VocabularyContentModified; +import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.validation.ValidationResult; +import cz.cvut.kbss.termit.util.throttle.Throttle; import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,6 +32,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.net.URI; import java.util.Collection; @@ -37,8 +40,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; @Component("cachingValidator") @Primary @@ -53,7 +58,7 @@ public class ResultCachingValidator implements VocabularyContentValidator { */ private final Map> vocabularyClosure = new ConcurrentHashMap<>(); - private final Map> validationCache = new HashMap<>(); + private final Map> validationCache = new HashMap<>(); /** * @return true when the cache contents are dirty and should be refreshed; false otherwise. @@ -62,12 +67,14 @@ public boolean isNotDirty(@NotNull URI originVocabularyIri) { return vocabularyClosure.containsKey(originVocabularyIri); } - private List getCached(@NotNull URI originVocabularyIri) { + private Optional> getCached(@NotNull URI originVocabularyIri) { synchronized (validationCache) { - return validationCache.getOrDefault(originVocabularyIri, List.of()); + return Optional.ofNullable(validationCache.get(originVocabularyIri)); } } + @Throttle("{#originVocabularyIri}") + @Transactional @Override public @NotNull ThrottledFuture> validate(@NotNull URI originVocabularyIri, @NotNull Collection vocabularyIris) { final Set iris = Set.copyOf(vocabularyIris); @@ -76,25 +83,35 @@ private List getCached(@NotNull URI originVocabularyIri) { return ThrottledFuture.done(List.of()); } - List cached = getCached(originVocabularyIri); - if (isNotDirty(originVocabularyIri)) { - return ThrottledFuture.done(cached); + Optional> cached = getCached(originVocabularyIri); + if (isNotDirty(originVocabularyIri) && cached.isPresent()) { + return ThrottledFuture.done(cached.get()); } - return ThrottledFuture.of(() -> runValidation(originVocabularyIri, iris)).setCachedResult(cached.isEmpty() ? null : cached); + return ThrottledFuture.of(() -> runValidation(originVocabularyIri, iris)).setCachedResult(cached.orElse(null)); } private @NotNull Collection runValidation(@NotNull URI originVocabularyIri, @NotNull final Set iris) { - if (isNotDirty(originVocabularyIri)) { - return getCached(originVocabularyIri); + Optional> cached = getCached(originVocabularyIri); + if (isNotDirty(originVocabularyIri) && cached.isPresent()) { + return cached.get(); } - final List results = getValidator().runValidation(iris); + final Collection results; + try { + // executes real validation + results = getValidator().validate(originVocabularyIri, iris).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TermItException(e); + } catch (ExecutionException e) { + throw new TermItException(e.getCause()); + } synchronized (validationCache) { vocabularyClosure.put(originVocabularyIri, Collections.unmodifiableCollection(iris)); - validationCache.put(originVocabularyIri, Collections.unmodifiableList(results)); + validationCache.put(originVocabularyIri, Collections.unmodifiableCollection(results)); } return results; diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java index 845448240..962b505c5 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java @@ -27,6 +27,7 @@ import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; +import cz.cvut.kbss.termit.util.throttle.Throttle; import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.apache.jena.rdf.model.Literal; import org.apache.jena.rdf.model.Model; @@ -142,6 +143,7 @@ private void loadOverrideRules(Model validationModel, String language) throws IO } } + @Throttle("{#originVocabularyIri}") @Transactional(readOnly = true) @Override public @NotNull ThrottledFuture> validate(final @NotNull URI originVocabularyIri, final @NotNull Collection vocabularyIris) { @@ -159,7 +161,9 @@ private void loadOverrideRules(Model validationModel, String language) throws IO protected synchronized List runValidation(@NotNull Collection vocabularyIris) { LOG.debug("Validating {}", vocabularyIris); try { + LOG.trace("Constructing model from RDF4J repository..."); final Model dataModel = getModelFromRdf4jRepository(vocabularyIris); + LOG.trace("Model constructed, running validation..."); // TODO: would be better to cache the validator, but its not thread safe org.topbraid.shacl.validation.ValidationReport report = new com.github.sgov.server.Validator() .validate(dataModel, validationModel); diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index 530de0969..abac7000e 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -144,21 +144,7 @@ public ResponseEntity authorizationException(HttpServletRequest reque public ResponseEntity authenticationException(HttpServletRequest request, AuthenticationException e) { LOG.warn("Authentication failure during HTTP request to {}: {}", request.getRequestURI(), e.getMessage()); LOG.atDebug().setCause(e).log(e.getMessage()); - return new ResponseEntity<>(errorInfo(request, e), HttpStatus.FORBIDDEN); - } - - /** - * Fired, for example, on method security violation - */ - @ExceptionHandler(AccessDeniedException.class) - public ResponseEntity accessDeniedException(HttpServletRequest request, AccessDeniedException e) { - LOG.atWarn().setMessage("[{}] Unauthorized access: {}").addArgument(() -> { - if (request.getUserPrincipal() != null) { - return request.getUserPrincipal().getName(); - } - return "(unknown user)"; - }).addArgument(e.getMessage()).log(); - return new ResponseEntity<>(errorInfo(request, e), HttpStatus.FORBIDDEN); + return new ResponseEntity<>(errorInfo(request, e), HttpStatus.UNAUTHORIZED); } @ExceptionHandler(ValidationException.class) diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index 1b0e63dd7..68b384f6f 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -99,7 +99,7 @@ public class ThrottleAspect implements LongRunningTaskRegister { private final Clock clock; - private final Executor transactionExecutor; + private final TransactionExecutor transactionExecutor; private final @NotNull AtomicReference lastClear; @@ -317,16 +317,16 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id // restore the security context SecurityContextHolder.setContext(securityContext.get()); try { - // update last run timestamp - synchronized (lastRun) { - lastRun.put(identifier, Instant.now(clock)); - } // fulfill the future if (withTransaction) { transactionExecutor.execute(throttledFuture::run); } else { throttledFuture.run(); } + // update last run timestamp + synchronized (lastRun) { + lastRun.put(identifier, Instant.now(clock)); + } } finally { // clear the security context SecurityContextHolder.clearContext(); diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index 0df141c05..de2f28d8f 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -96,28 +96,19 @@ public boolean isDone() { } /** - * Does not execute the task, blocks the current thread until some result is available. - * - * @return cached result when available, otherwise awaits future resolution. + * Does not execute the task, blocks the current thread until the result is available. */ @Override public T get() throws InterruptedException, ExecutionException { - if (!isDone() && this.cachedResult != null) { - return this.cachedResult; - } return future.get(); } /** - * Does not execute the task, blocks the current thread until some result is available. - * @return cached result when available, otherwise awaits future resolution. + * Does not execute the task, blocks the current thread until the result is available. */ @Override public T get(long timeout, @NotNull TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { - if (!isDone() && this.cachedResult != null) { - return this.cachedResult; - } return future.get(timeout, unit); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java new file mode 100644 index 000000000..7fe6112a3 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java @@ -0,0 +1,81 @@ +package cz.cvut.kbss.termit.websocket; + +import cz.cvut.kbss.termit.rest.BaseController; +import cz.cvut.kbss.termit.service.IdentifierResolver; +import cz.cvut.kbss.termit.util.Configuration; +import org.jetbrains.annotations.NotNull; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.simp.user.DestinationUserNameProvider; + +import java.security.Principal; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public class BaseWebSocketController extends BaseController { + + protected final SimpMessagingTemplate messagingTemplate; + + protected BaseWebSocketController(IdentifierResolver idResolver, Configuration config, + SimpMessagingTemplate messagingTemplate) { + super(idResolver, config); + this.messagingTemplate = messagingTemplate; + } + + /** + * Resolves session id, when present, and sends to the specific session. + * When session id is not present, sends it to all sessions of specific user. + * + * @param destination the destination (without user prefix) + * @param payload payload to send + * @param replyHeaders native headers for the reply + * @param sourceHeaders original headers containing session id or name of the user + */ + protected void sendToSession(@NotNull String destination, @NotNull Object payload, + @NotNull Map replyHeaders, @NotNull MessageHeaders sourceHeaders) { + getSessionId(sourceHeaders) + .ifPresentOrElse(sessionId -> { // session id present + StompHeaderAccessor headerAccessor = StompHeaderAccessor.create(StompCommand.MESSAGE); + // add reply headers as native headers + replyHeaders.forEach((name, value) -> headerAccessor.addNativeHeader(name, Objects.toString(value))); + headerAccessor.setSessionId(sessionId); // pass session id to new headers + // send to user session + messagingTemplate.convertAndSendToUser(sessionId, destination, payload, headerAccessor.toMessageHeaders()); + }, + // session id not present, send to all user sessions + () -> getUser(sourceHeaders).ifPresent(user -> messagingTemplate.convertAndSendToUser(user, destination, payload, replyHeaders)) + ); + } + + /** + * Resolves name which can be used to send a message to the user with {@link SimpMessagingTemplate#convertAndSendToUser}. + * + * @return name or session id, or empty when information is not available. + */ + protected @NotNull Optional getUser(@NotNull MessageHeaders messageHeaders) { + return getUserName(messageHeaders).or(() -> getSessionId(messageHeaders)); + } + + private @NotNull Optional getSessionId(@NotNull MessageHeaders messageHeaders) { + return Optional.ofNullable(SimpMessageHeaderAccessor.getSessionId(messageHeaders)); + } + + /** + * Resolves the name of the user + * + * @return the name or null + */ + private @NotNull Optional getUserName(MessageHeaders headers) { + Principal principal = SimpMessageHeaderAccessor.getUser(headers); + if (principal != null) { + final String name = (principal instanceof DestinationUserNameProvider provider ? + provider.getDestinationUserName() : principal.getName()); + return Optional.ofNullable(name); + } + return Optional.empty(); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java b/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java deleted file mode 100644 index 55718666d..000000000 --- a/src/main/java/cz/cvut/kbss/termit/websocket/ResultWithHeaders.java +++ /dev/null @@ -1,69 +0,0 @@ -package cz.cvut.kbss.termit.websocket; - -import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; -import org.springframework.messaging.simp.annotation.SendToUser; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -/** - * Wrapper carrying a result from WebSocket controller - * including the {@link #payload}, {@link #destination} and {@link #headers} for the resulting message. - *

    - * Do not combine with other method-return-value handlers (like {@link SendTo @SendTo}) - *

    - * The {@code ResultWithHeaders} is then handled by {@link WebSocketMessageWithHeadersValueHandler}. - * Every value returned from a controller method - * can be handled only by a single {@link HandlerMethodReturnValueHandler}. - * Annotations like {@link SendTo @SendTo}/{@link SendToUser @SendToUser} - * are handled by separate return value handlers, so only one can be used simultaneously. - * - * @param payload The actual result of the method - * @param destination The destination channel where the message will be sent - * @param headers Headers that will overwrite headers in the message. - * @param The type of the payload - * @see WebSocketMessageWithHeadersValueHandler - * @see HandlerMethodReturnValueHandler - */ -public record ResultWithHeaders(T payload, @NotNull String destination, @NotNull Map headers, - boolean toUser) { - - public static ResultWithHeadersBuilder result(T payload) { - return new ResultWithHeadersBuilder<>(payload); - } - - public static class ResultWithHeadersBuilder { - - private final T payload; - - private @Nullable Map headers = null; - - private ResultWithHeadersBuilder(T payload) { - this.payload = payload; - } - - /** - * All values will be mapped to strings with {@link Object#toString()} - */ - public ResultWithHeadersBuilder withHeaders(@NotNull Map headers) { - this.headers = new HashMap<>(); - headers.forEach((key, value) -> this.headers.put(key, Objects.toString(value))); - this.headers = Collections.unmodifiableMap(this.headers); - return this; - } - - public ResultWithHeaders sendTo(String destination) { - return new ResultWithHeaders<>(payload, destination, headers == null ? Map.of() : headers, false); - } - - public ResultWithHeaders sendToUser(String userDestination) { - return new ResultWithHeaders<>(payload, userDestination, headers == null ? Map.of() : headers, true); - } - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index b43fa355b..d7e1a3a3d 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -3,38 +3,39 @@ import cz.cvut.kbss.termit.event.VocabularyValidationFinished; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.validation.ValidationResult; -import cz.cvut.kbss.termit.rest.BaseController; import cz.cvut.kbss.termit.security.SecurityConstants; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.throttle.CachableFuture; +import org.jetbrains.annotations.NotNull; import org.springframework.context.event.EventListener; +import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import java.net.URI; import java.util.Collection; -import java.util.List; import java.util.Map; import java.util.Optional; -import static cz.cvut.kbss.termit.websocket.ResultWithHeaders.result; - @Controller @MessageMapping("/vocabularies") @PreAuthorize("hasRole('" + SecurityConstants.ROLE_RESTRICTED_USER + "')") -public class VocabularySocketController extends BaseController { +public class VocabularySocketController extends BaseWebSocketController { + + private static final String DESTINATION_VOCABULARIES_VALIDATION = "/vocabularies/validation"; private final VocabularyService vocabularyService; protected VocabularySocketController(IdentifierResolver idResolver, Configuration config, - VocabularyService vocabularyService) { - super(idResolver, config); + SimpMessagingTemplate messagingTemplate, VocabularyService vocabularyService) { + super(idResolver, config, messagingTemplate); this.vocabularyService = vocabularyService; } @@ -43,23 +44,34 @@ protected VocabularySocketController(IdentifierResolver idResolver, Configuratio * Immediately responds with a result from the cache, if available. */ @MessageMapping("/{localName}/validate") - public ResultWithHeaders> validateVocabulary(@DestinationVariable String localName, - @Header(name = Constants.QueryParams.NAMESPACE, - required = false) Optional namespace) { + public void validateVocabulary(@DestinationVariable String localName, + @Header(name = Constants.QueryParams.NAMESPACE, + required = false) Optional namespace, + @NotNull MessageHeaders messageHeaders) { final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); final Vocabulary vocabulary = vocabularyService.getReference(identifier); final CachableFuture> future = vocabularyService.validateContents(vocabulary.getUri()); - return future.getNow() - .map(validationResults -> result(validationResults).withHeaders(Map.of("vocabulary", identifier, "cached", !future.isDone())) - .sendToUser("/vocabularies/validation")) - .orElse(null); + future.getNow().ifPresent(validationResults -> + sendToSession( + DESTINATION_VOCABULARIES_VALIDATION, + validationResults, + Map.of("vocabulary", identifier, + "cached", !future.isDone()), + messageHeaders + )); } + /** + * Publishes results of validation to users. + */ @EventListener - public ResultWithHeaders> onVocabularyValidationFinished(VocabularyValidationFinished event) { - return result(event.getValidationResults()).withHeaders(Map.of("vocabulary", event.getOriginVocabularyIri(), "cached", false)) - .sendTo("/vocabularies/validation"); + public void onVocabularyValidationFinished(VocabularyValidationFinished event) { + messagingTemplate.convertAndSend( + DESTINATION_VOCABULARIES_VALIDATION, + event.getValidationResults(), + Map.of("vocabulary", event.getOriginVocabularyIri(), "cached", false) + ); } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index bbaec2b69..b243abf5f 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -143,7 +143,8 @@ public ErrorInfo authorizationException(Message message, AuthorizationExcepti @MessageExceptionHandler(AuthenticationException.class) public ErrorInfo authenticationException(Message message, AuthenticationException e) { LOG.atDebug().setCause(e).log(e.getMessage()); - LOG.error("Authentication failure during message processing: {}\nMessage: {}", e.getMessage(), message.toString()); + LOG.atError().setMessage("Authentication failure during message processing: {}\nMessage: {}") + .addArgument(e.getMessage()).addArgument(message::toString).log(); return errorInfo(message, e); } @@ -152,13 +153,11 @@ public ErrorInfo authenticationException(Message message, AuthenticationExcep */ @MessageExceptionHandler(AccessDeniedException.class) public ErrorInfo accessDeniedException(Message message, AccessDeniedException e) { - LOG.atWarn().setMessage("[{}] Unauthorized access: {}").addArgument(() -> { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); - if (accessor.getUser() != null) { - return accessor.getUser().getName(); - } - return "(unknown user)"; - }).addArgument(e.getMessage()).log(); + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + if (accessor.getUser() != null) { + LOG.atWarn().setMessage("[{}] Unauthorized access: {}").addArgument(() -> accessor.getUser().getName()) + .addArgument(e.getMessage()).log(); + } return errorInfo(message, e); } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java deleted file mode 100644 index a0d9ecb95..000000000 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketMessageWithHeadersValueHandler.java +++ /dev/null @@ -1,47 +0,0 @@ -package cz.cvut.kbss.termit.websocket.handler; - -import cz.cvut.kbss.termit.exception.UnsupportedOperationException; -import cz.cvut.kbss.termit.websocket.ResultWithHeaders; -import org.jetbrains.annotations.NotNull; -import org.springframework.core.MethodParameter; -import org.springframework.messaging.Message; -import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; -import org.springframework.messaging.simp.SimpMessageHeaderAccessor; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.messaging.simp.annotation.support.MissingSessionUserException; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; - -public class WebSocketMessageWithHeadersValueHandler implements HandlerMethodReturnValueHandler { - - private final SimpMessagingTemplate simpMessagingTemplate; - - public WebSocketMessageWithHeadersValueHandler(SimpMessagingTemplate simpMessagingTemplate) { - this.simpMessagingTemplate = simpMessagingTemplate; - } - - @Override - public boolean supportsReturnType(MethodParameter returnType) { - return ResultWithHeaders.class.isAssignableFrom(returnType.getParameterType()); - } - - @Override - public void handleReturnValue(Object returnValue, @NotNull MethodParameter returnType, - @NotNull Message message) { - if (returnValue == null) return; - if (returnValue instanceof ResultWithHeaders resultWithHeaders) { - final StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message); - resultWithHeaders.headers().forEach(headerAccessor::setNativeHeader); - if (resultWithHeaders.toUser()) { - final String sessionId = SimpMessageHeaderAccessor.getSessionId(headerAccessor.toMessageHeaders()); - if (sessionId == null || sessionId.isBlank()) { - throw new MissingSessionUserException(message); - } - simpMessagingTemplate.convertAndSendToUser(sessionId, resultWithHeaders.destination(), resultWithHeaders.payload(), headerAccessor.toMessageHeaders()); - } else { - simpMessagingTemplate.convertAndSend(resultWithHeaders.destination(), resultWithHeaders.payload(), headerAccessor.toMessageHeaders()); - } - return; - } - throw new UnsupportedOperationException("Unable to process returned value: " + returnValue + " of type " + returnType.getParameterType() + " from " + returnType.getMethod()); - } -} From 504add369ff407b65a9ac4339f9b9f3407db0c49 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 5 Sep 2024 12:15:41 +0200 Subject: [PATCH 095/150] [Performance #287] Resolve invalid thread synchronization --- .../cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index 68b384f6f..2755d9558 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -150,7 +150,7 @@ private static StandardEvaluationContext makeDefaultContext() { * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or {@link Future} * @implNote Around advice configured in {@code spring-aop.xml} */ - public synchronized @Nullable Object throttleMethodCall(@NotNull ProceedingJoinPoint joinPoint, + public @Nullable Object throttleMethodCall(@NotNull ProceedingJoinPoint joinPoint, @NotNull Throttle throttleAnnotation) throws Throwable { // if the current thread is already executing a throttled code, we want to skip further throttling @@ -165,6 +165,12 @@ private static StandardEvaluationContext makeDefaultContext() { return result; } + return doThrottle(joinPoint, throttleAnnotation); + } + + private synchronized @Nullable Object doThrottle(@NotNull ProceedingJoinPoint joinPoint, + @NotNull Throttle throttleAnnotation) throws Throwable { + final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // construct the throttle instance key From 36e0a7f2a7e653eb04223c6bc2b081f2ffac5076 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 5 Sep 2024 12:43:25 +0200 Subject: [PATCH 096/150] [Performance #287] Trigger validation on vocabulary modification event, fix event names, move business vocabulary service test --- ...va => VocabularyContentModifiedEvent.java} | 4 ++-- ...=> VocabularyValidationFinishedEvent.java} | 8 +++---- .../kbss/termit/persistence/dao/TermDao.java | 8 +++---- .../validation/ResultCachingValidator.java | 4 ++-- .../persistence/validation/Validator.java | 4 ++-- .../service/business/VocabularyService.java | 12 ++++++++++ .../websocket/VocabularySocketController.java | 4 ++-- .../termit/persistence/dao/TermDaoTest.java | 20 ++++++++-------- .../ResultCachingValidatorTest.java | 7 ++---- .../persistence/validation/ValidatorTest.java | 7 +++--- .../VocabularyServiceTest.java | 23 +++++++++++++++---- 11 files changed, 62 insertions(+), 39 deletions(-) rename src/main/java/cz/cvut/kbss/termit/event/{VocabularyContentModified.java => VocabularyContentModifiedEvent.java} (89%) rename src/main/java/cz/cvut/kbss/termit/event/{VocabularyValidationFinished.java => VocabularyValidationFinishedEvent.java} (83%) rename src/test/java/cz/cvut/kbss/termit/service/{repository => business}/VocabularyServiceTest.java (95%) diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java similarity index 89% rename from src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java rename to src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java index e3c18117d..cc1691752 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java @@ -28,11 +28,11 @@ *

    * This typically means a term is added, removed or modified. Modification of vocabulary metadata themselves is not considered here. */ -public class VocabularyContentModified extends ApplicationEvent { +public class VocabularyContentModifiedEvent extends ApplicationEvent { private final URI vocabularyIri; - public VocabularyContentModified(Object source, @NotNull URI vocabularyIri) { + public VocabularyContentModifiedEvent(Object source, @NotNull URI vocabularyIri) { super(source); Objects.requireNonNull(vocabularyIri); this.vocabularyIri = vocabularyIri; diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinished.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java similarity index 83% rename from src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinished.java rename to src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java index 055a9a174..20910f787 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinished.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java @@ -13,7 +13,7 @@ /** * Indicates that validation for a set of vocabularies was finished. */ -public class VocabularyValidationFinished extends ApplicationEvent { +public class VocabularyValidationFinishedEvent extends ApplicationEvent { /** * Vocabulary closure of {@link #originVocabularyIri}. @@ -39,9 +39,9 @@ public class VocabularyValidationFinished extends ApplicationEvent { * @param vocabularyIris IRI of the vocabulary on which the validation was triggered. * @param validationResults results of the validation */ - public VocabularyValidationFinished(@NotNull Object source, @NotNull URI originVocabularyIri, - @NotNull Collection vocabularyIris, - @NotNull List validationResults) { + public VocabularyValidationFinishedEvent(@NotNull Object source, @NotNull URI originVocabularyIri, + @NotNull Collection vocabularyIris, + @NotNull List validationResults) { super(source); this.vocabularyIris = Collections.unmodifiableCollection(vocabularyIris); this.validationResults = Collections.unmodifiableCollection(validationResults); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java index 34ded7f67..50d138f16 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java @@ -28,7 +28,7 @@ import cz.cvut.kbss.termit.event.AssetPersistEvent; import cz.cvut.kbss.termit.event.AssetUpdateEvent; import cz.cvut.kbss.termit.event.EvictCacheEvent; -import cz.cvut.kbss.termit.event.VocabularyContentModified; +import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.model.AbstractTerm; import cz.cvut.kbss.termit.model.Term; @@ -174,7 +174,7 @@ public void persist(Term entity, Vocabulary vocabulary) { entity.setVocabulary(null); // This is inferred em.persist(entity, descriptorFactory.termDescriptor(vocabulary)); evictCachedSubTerms(Collections.emptySet(), entity.getParentTerms()); - eventPublisher.publishEvent(new VocabularyContentModified(this, vocabulary.getUri())); + eventPublisher.publishEvent(new VocabularyContentModifiedEvent(this, vocabulary.getUri())); eventPublisher.publishEvent(new AssetPersistEvent(this, entity)); } catch (RuntimeException e) { throw new PersistenceException(e); @@ -194,7 +194,7 @@ public Term update(Term entity) { eventPublisher.publishEvent(new AssetUpdateEvent(this, entity)); evictCachedSubTerms(original.getParentTerms(), entity.getParentTerms()); final Term result = em.merge(entity, descriptorFactory.termDescriptor(entity)); - eventPublisher.publishEvent(new VocabularyContentModified(this, original.getVocabulary())); + eventPublisher.publishEvent(new VocabularyContentModifiedEvent(this, original.getVocabulary())); return result; } catch (RuntimeException e) { throw new PersistenceException(e); @@ -790,7 +790,7 @@ public List findAllUnused(Vocabulary vocabulary) { public void remove(Term entity) { super.remove(entity); evictCachedSubTerms(entity.getParentTerms(), Collections.emptySet()); - eventPublisher.publishEvent(new VocabularyContentModified(this, entity.getVocabulary())); + eventPublisher.publishEvent(new VocabularyContentModifiedEvent(this, entity.getVocabulary())); } @Override diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 11fede874..3c958ff12 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -18,7 +18,7 @@ package cz.cvut.kbss.termit.persistence.validation; import cz.cvut.kbss.termit.event.EvictCacheEvent; -import cz.cvut.kbss.termit.event.VocabularyContentModified; +import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.util.throttle.Throttle; @@ -123,7 +123,7 @@ Validator getValidator() { } @EventListener - public void evictVocabularyCache(VocabularyContentModified event) { + public void evictVocabularyCache(VocabularyContentModifiedEvent event) { LOG.debug("Vocabulary content modified, marking cache as dirty for {}.", event.getVocabularyIri()); // marked as dirty for specified vocabulary vocabularyClosure.remove(event.getVocabularyIri()); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java index 962b505c5..ab4c6118e 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java @@ -21,7 +21,7 @@ import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jsonld.JsonLd; -import cz.cvut.kbss.termit.event.VocabularyValidationFinished; +import cz.cvut.kbss.termit.event.VocabularyValidationFinishedEvent; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper; @@ -153,7 +153,7 @@ private void loadOverrideRules(Model validationModel, String language) throws IO return ThrottledFuture.of(() -> { final List results = runValidation(vocabularyIris); - eventPublisher.publishEvent(new VocabularyValidationFinished(this, originVocabularyIri, vocabularyIris, results)); + eventPublisher.publishEvent(new VocabularyValidationFinishedEvent(this, originVocabularyIri, vocabularyIris, results)); return results; }); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index d92c85e15..0d9b66f2c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -24,6 +24,7 @@ import cz.cvut.kbss.termit.dto.acl.AccessControlListDto; import cz.cvut.kbss.termit.dto.listing.TermDto; import cz.cvut.kbss.termit.dto.listing.VocabularyDto; +import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.event.VocabularyCreatedEvent; import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.model.Vocabulary; @@ -53,6 +54,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PostFilter; import org.springframework.security.access.prepost.PreAuthorize; @@ -119,6 +121,16 @@ public VocabularyService(VocabularyRepositoryService repositoryService, this.context = context; } + /** + * Receives {@link VocabularyContentModifiedEvent} and triggers validation. + * The goal for this is to get the results cached and do not force users to wait for validation + * when they request it. + */ + @EventListener + public void onVocabularyContentModified(VocabularyContentModifiedEvent event) { + repositoryService.validateContents(event.getVocabularyIri()); + } + @Override @PostFilter("@vocabularyAuthorizationService.canRead(filterObject)") public List findAll() { diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index d7e1a3a3d..b1056789d 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.websocket; -import cz.cvut.kbss.termit.event.VocabularyValidationFinished; +import cz.cvut.kbss.termit.event.VocabularyValidationFinishedEvent; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.security.SecurityConstants; @@ -67,7 +67,7 @@ public void validateVocabulary(@DestinationVariable String localName, * Publishes results of validation to users. */ @EventListener - public void onVocabularyValidationFinished(VocabularyValidationFinished event) { + public void onVocabularyValidationFinished(VocabularyValidationFinishedEvent event) { messagingTemplate.convertAndSend( DESTINATION_VOCABULARIES_VALIDATION, event.getValidationResults(), diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java index 506ad4d67..461f2576b 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermDaoTest.java @@ -27,7 +27,7 @@ import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.event.AssetPersistEvent; import cz.cvut.kbss.termit.event.AssetUpdateEvent; -import cz.cvut.kbss.termit.event.VocabularyContentModified; +import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.Term_; @@ -369,9 +369,9 @@ void persistPublishesVocabularyContentModifiedEvent() { final ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); verify(eventPublisher, atLeastOnce()).publishEvent(captor.capture()); - final Optional evt = captor.getAllValues().stream() - .filter(VocabularyContentModified.class::isInstance) - .map(VocabularyContentModified.class::cast).findFirst(); + final Optional evt = captor.getAllValues().stream() + .filter(VocabularyContentModifiedEvent.class::isInstance) + .map(VocabularyContentModifiedEvent.class::cast).findFirst(); assertTrue(evt.isPresent()); assertEquals(vocabulary.getUri(), evt.get().getVocabularyIri()); } @@ -430,9 +430,9 @@ void updatePublishesVocabularyContentModifiedEvent() { transactional(() -> sut.update(term)); final ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); verify(eventPublisher, atLeastOnce()).publishEvent(captor.capture()); - final Optional evt = captor.getAllValues().stream() - .filter(VocabularyContentModified.class::isInstance) - .map(VocabularyContentModified.class::cast).findFirst(); + final Optional evt = captor.getAllValues().stream() + .filter(VocabularyContentModifiedEvent.class::isInstance) + .map(VocabularyContentModifiedEvent.class::cast).findFirst(); assertTrue(evt.isPresent()); assertEquals(vocabulary.getUri(), evt.get().getVocabularyIri()); } @@ -1306,9 +1306,9 @@ void removePublishesVocabularyContentModifiedEvent() { transactional(() -> sut.remove(term)); final ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); verify(eventPublisher, atLeastOnce()).publishEvent(captor.capture()); - final Optional evt = captor.getAllValues().stream() - .filter(VocabularyContentModified.class::isInstance) - .map(VocabularyContentModified.class::cast).findFirst(); + final Optional evt = captor.getAllValues().stream() + .filter(VocabularyContentModifiedEvent.class::isInstance) + .map(VocabularyContentModifiedEvent.class::cast).findFirst(); assertTrue(evt.isPresent()); assertEquals(vocabulary.getUri(), evt.get().getVocabularyIri()); } diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java index 101653309..123a15243 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java @@ -18,10 +18,9 @@ package cz.cvut.kbss.termit.persistence.validation; import cz.cvut.kbss.termit.environment.Generator; -import cz.cvut.kbss.termit.event.VocabularyContentModified; +import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.validation.ValidationResult; -import cz.cvut.kbss.termit.persistence.dao.VocabularyDao; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,14 +31,12 @@ import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Set; import static cz.cvut.kbss.termit.util.throttle.TestFutureRunner.runFuture; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.anyCollection; -import static org.mockito.Mockito.anySet; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -99,7 +96,7 @@ void evictCacheClearsCachedValidationResults() throws Exception { final Set vocabularies = Collections.singleton(vocabulary); final Collection resultOne = runFuture(sut.validate(vocabulary, vocabularies)); verify(validator).runValidation(vocabularies); - sut.evictVocabularyCache(new VocabularyContentModified(this, vocabulary)); + sut.evictVocabularyCache(new VocabularyContentModifiedEvent(this, vocabulary)); final Collection resultTwo = runFuture(sut.validate(vocabulary, vocabularies)); verify(validator, times(2)).runValidation(vocabularies); assertEquals(resultOne, resultTwo); diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java index 7bd25aa3a..0a44e04e7 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ValidatorTest.java @@ -20,7 +20,7 @@ import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; -import cz.cvut.kbss.termit.event.VocabularyValidationFinished; +import cz.cvut.kbss.termit.event.VocabularyValidationFinishedEvent; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.User; @@ -42,7 +42,6 @@ import java.net.URI; import java.util.Collection; import java.util.Collections; -import java.util.List; import static cz.cvut.kbss.termit.util.throttle.TestFutureRunner.runFuture; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -114,8 +113,8 @@ void publishesVocabularyValidationFinishedEventAfterValidation() { ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ApplicationEvent.class); verify(eventPublisher).publishEvent(eventCaptor.capture()); final ApplicationEvent event = eventCaptor.getValue(); - assertInstanceOf(VocabularyValidationFinished.class, event); - final VocabularyValidationFinished finished = (VocabularyValidationFinished) event; + assertInstanceOf(VocabularyValidationFinishedEvent.class, event); + final VocabularyValidationFinishedEvent finished = (VocabularyValidationFinishedEvent) event; assertIterableEquals(result, finished.getValidationResults()); assertIterableEquals(iris, finished.getVocabularyIris()); }); diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java similarity index 95% rename from src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java rename to src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java index 26512aec2..e18edd119 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package cz.cvut.kbss.termit.service.repository; +package cz.cvut.kbss.termit.service.business; import cz.cvut.kbss.termit.dto.Snapshot; import cz.cvut.kbss.termit.dto.acl.AccessControlListDto; @@ -23,6 +23,7 @@ import cz.cvut.kbss.termit.dto.listing.VocabularyDto; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.event.VocabularyCreatedEvent; import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.model.Term; @@ -34,10 +35,9 @@ import cz.cvut.kbss.termit.model.util.HasIdentifier; import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper; import cz.cvut.kbss.termit.persistence.snapshot.SnapshotCreator; -import cz.cvut.kbss.termit.service.business.AccessControlListService; -import cz.cvut.kbss.termit.service.business.TermService; -import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.service.export.ExportFormat; +import cz.cvut.kbss.termit.service.repository.ChangeRecordService; +import cz.cvut.kbss.termit.service.repository.VocabularyRepositoryService; import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.TypeAwareResource; @@ -49,6 +49,7 @@ import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationEvent; @@ -73,6 +74,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -105,6 +108,7 @@ class VocabularyServiceTest { @Mock private ApplicationContext appContext; + @Spy @InjectMocks private VocabularyService sut; @@ -391,4 +395,15 @@ void getExcelTemplateFileReturnsResourceRepresentingExcelTemplateFile() throws E final File expectedFile = new File(getClass().getClassLoader().getResource("template/termit-import.xlsx").toURI()); assertEquals(expectedFile, result.getFile()); } + + /** + * The goal for this is to get the results cached and do not force users to wait for validation + * when they request it. + */ + @Test + void publishingVocabularyContentModifiedEventTriggersContentsValidation() { + final VocabularyContentModifiedEvent event = new VocabularyContentModifiedEvent(this, Generator.generateUri()); + sut.onVocabularyContentModified(event); + verify(repositoryService).validateContents(event.getVocabularyIri()); + } } From 3a86e8b45efa451819366b24420d6b839f6ddf10 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 5 Sep 2024 13:49:26 +0200 Subject: [PATCH 097/150] [Performance #287] Introduce base class for Vocabulary related event --- .../event/VocabularyContentModifiedEvent.java | 14 ++-------- .../termit/event/VocabularyCreatedEvent.java | 10 ++++--- .../kbss/termit/event/VocabularyEvent.java | 28 +++++++++++++++++++ .../VocabularyValidationFinishedEvent.java | 21 ++++---------- .../event/VocabularyWillBeRemovedEvent.java | 14 +++------- .../termit/persistence/dao/VocabularyDao.java | 2 ++ .../service/business/ResourceService.java | 2 +- .../service/business/VocabularyService.java | 12 ++++---- .../websocket/VocabularySocketController.java | 2 +- .../persistence/dao/VocabularyDaoTest.java | 4 +-- .../ResultCachingValidatorTest.java | 24 ++++++++-------- .../business/VocabularyServiceTest.java | 10 ++++--- 12 files changed, 77 insertions(+), 66 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java index cc1691752..b6c9cfd17 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java @@ -18,27 +18,17 @@ package cz.cvut.kbss.termit.event; import org.jetbrains.annotations.NotNull; -import org.springframework.context.ApplicationEvent; import java.net.URI; -import java.util.Objects; /** * Represents an event of modification of the content of a vocabulary. *

    * This typically means a term is added, removed or modified. Modification of vocabulary metadata themselves is not considered here. */ -public class VocabularyContentModifiedEvent extends ApplicationEvent { - - private final URI vocabularyIri; +public class VocabularyContentModifiedEvent extends VocabularyEvent { public VocabularyContentModifiedEvent(Object source, @NotNull URI vocabularyIri) { - super(source); - Objects.requireNonNull(vocabularyIri); - this.vocabularyIri = vocabularyIri; - } - - public URI getVocabularyIri() { - return vocabularyIri; + super(source, vocabularyIri); } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java index e1da1aeab..6e3aceb5a 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java @@ -17,14 +17,16 @@ */ package cz.cvut.kbss.termit.event; -import org.springframework.context.ApplicationEvent; +import org.jetbrains.annotations.NotNull; + +import java.net.URI; /** * Indicates that a vocabulary has been created. */ -public class VocabularyCreatedEvent extends ApplicationEvent { +public class VocabularyCreatedEvent extends VocabularyEvent { - public VocabularyCreatedEvent(Object source) { - super(source); + public VocabularyCreatedEvent(Object source, @NotNull URI vocabularyIri) { + super(source, vocabularyIri); } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java new file mode 100644 index 000000000..4314cd6e3 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java @@ -0,0 +1,28 @@ +package cz.cvut.kbss.termit.event; + +import org.jetbrains.annotations.NotNull; +import org.springframework.context.ApplicationEvent; + +import java.net.URI; +import java.util.Objects; + +/** + * Base class for vocabulary related events + */ +public abstract class VocabularyEvent extends ApplicationEvent { + protected final URI vocabularyIri; + + protected VocabularyEvent(Object source, @NotNull URI vocabularyIri) { + super(source); + Objects.requireNonNull(vocabularyIri); + this.vocabularyIri = vocabularyIri; + } + + /** + * The identifier of the vocabulary to which this event is bound + * @return vocabulary IRI + */ + public URI getVocabularyIri() { + return vocabularyIri; + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java index 20910f787..9795036b7 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java @@ -13,11 +13,11 @@ /** * Indicates that validation for a set of vocabularies was finished. */ -public class VocabularyValidationFinishedEvent extends ApplicationEvent { +public class VocabularyValidationFinishedEvent extends VocabularyEvent { /** - * Vocabulary closure of {@link #originVocabularyIri}. - * IRIs of vocabularies that are imported by {@link #originVocabularyIri} and were part of the validation. + * Vocabulary closure of {@link #vocabularyIri}. + * IRIs of vocabularies that are imported by {@link #vocabularyIri} and were part of the validation. */ @NotNull @Unmodifiable @@ -27,25 +27,18 @@ public class VocabularyValidationFinishedEvent extends ApplicationEvent { @Unmodifiable private final Collection validationResults; - /** - * IRI of the vocabulary on which the validation was triggered. - */ - @NotNull - private final URI originVocabularyIri; - /** * @param source the source of the event - * @param originVocabularyIri Vocabulary closure of {@link #originVocabularyIri}. + * @param originVocabularyIri Vocabulary closure of {@link #vocabularyIri}. * @param vocabularyIris IRI of the vocabulary on which the validation was triggered. * @param validationResults results of the validation */ public VocabularyValidationFinishedEvent(@NotNull Object source, @NotNull URI originVocabularyIri, @NotNull Collection vocabularyIris, @NotNull List validationResults) { - super(source); + super(source, originVocabularyIri); this.vocabularyIris = Collections.unmodifiableCollection(vocabularyIris); this.validationResults = Collections.unmodifiableCollection(validationResults); - this.originVocabularyIri = originVocabularyIri; } public @NotNull Collection getVocabularyIris() { @@ -55,8 +48,4 @@ public VocabularyValidationFinishedEvent(@NotNull Object source, @NotNull URI or public @NotNull Collection getValidationResults() { return validationResults; } - - public @NotNull URI getOriginVocabularyIri() { - return originVocabularyIri; - } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java index 3fed1f16e..96916d450 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java @@ -1,21 +1,15 @@ package cz.cvut.kbss.termit.event; -import org.springframework.context.ApplicationEvent; +import org.jetbrains.annotations.NotNull; import java.net.URI; /** * Indicates that a Vocabulary will be removed */ -public class VocabularyWillBeRemovedEvent extends ApplicationEvent { - private final URI vocabulary; +public class VocabularyWillBeRemovedEvent extends VocabularyEvent { - public VocabularyWillBeRemovedEvent(Object source, URI vocabulary) { - super(source); - this.vocabulary = vocabulary; - } - - public URI getVocabulary() { - return vocabulary; + public VocabularyWillBeRemovedEvent(Object source, @NotNull URI vocabularyIri) { + super(source, vocabularyIri); } } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index 84bf345cd..dfe939163 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -30,6 +30,7 @@ import cz.cvut.kbss.termit.event.AssetPersistEvent; import cz.cvut.kbss.termit.event.AssetUpdateEvent; import cz.cvut.kbss.termit.event.RefreshLastModifiedEvent; +import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.event.VocabularyWillBeRemovedEvent; import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.model.Glossary; @@ -196,6 +197,7 @@ public void persist(Vocabulary entity) { } refreshLastModified(); eventPublisher.publishEvent(new AssetPersistEvent(this, entity)); + eventPublisher.publishEvent(new VocabularyContentModifiedEvent(this, entity.getUri())); } catch (RuntimeException e) { throw new PersistenceException(e); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java index faa633d75..f3d7a9cc7 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java @@ -98,7 +98,7 @@ public ResourceService(ResourceRepositoryService repositoryService, DocumentMana */ @EventListener public void onVocabularyRemoval(VocabularyWillBeRemovedEvent event) { - vocabularyService.find(event.getVocabulary()).ifPresent(vocabulary -> { + vocabularyService.find(event.getVocabularyIri()).ifPresent(vocabulary -> { if(vocabulary.getDocument() != null) { remove(vocabulary.getDocument()); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 0d9b66f2c..7376fa50c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -26,6 +26,7 @@ import cz.cvut.kbss.termit.dto.listing.VocabularyDto; import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.event.VocabularyCreatedEvent; +import cz.cvut.kbss.termit.event.VocabularyEvent; import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.acl.AccessControlList; @@ -51,6 +52,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.context.annotation.Lazy; @@ -126,8 +128,8 @@ public VocabularyService(VocabularyRepositoryService repositoryService, * The goal for this is to get the results cached and do not force users to wait for validation * when they request it. */ - @EventListener - public void onVocabularyContentModified(VocabularyContentModifiedEvent event) { + @EventListener({VocabularyContentModifiedEvent.class, VocabularyCreatedEvent.class}) + public void onVocabularyContentModified(VocabularyEvent event) { repositoryService.validateContents(event.getVocabularyIri()); } @@ -181,7 +183,7 @@ public void persist(Vocabulary instance) { repositoryService.persist(instance); final AccessControlList acl = aclService.createFor(instance); instance.setAcl(acl.getUri()); - eventPublisher.publishEvent(new VocabularyCreatedEvent(instance)); + eventPublisher.publishEvent(new VocabularyCreatedEvent(this, instance.getUri())); } @Override @@ -244,7 +246,7 @@ public Vocabulary importVocabulary(boolean rename, MultipartFile file) { final Vocabulary imported = repositoryService.importVocabulary(rename, file); final AccessControlList acl = aclService.createFor(imported); imported.setAcl(acl.getUri()); - eventPublisher.publishEvent(new VocabularyCreatedEvent(imported)); + eventPublisher.publishEvent(new VocabularyCreatedEvent(this, imported.getUri())); return imported; } @@ -384,7 +386,7 @@ public Integer getTermCount(Vocabulary vocabulary) { @PreAuthorize("@vocabularyAuthorizationService.canCreateSnapshot(#vocabulary)") public Snapshot createSnapshot(Vocabulary vocabulary) { final Snapshot s = getSnapshotCreator().createSnapshot(vocabulary); - eventPublisher.publishEvent(new VocabularyCreatedEvent(s)); + eventPublisher.publishEvent(new VocabularyCreatedEvent(this, s.getUri())); cloneAccessControlList(s, vocabulary); return s; } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index b1056789d..457229a1d 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -71,7 +71,7 @@ public void onVocabularyValidationFinished(VocabularyValidationFinishedEvent eve messagingTemplate.convertAndSend( DESTINATION_VOCABULARIES_VALIDATION, event.getValidationResults(), - Map.of("vocabulary", event.getOriginVocabularyIri(), "cached", false) + Map.of("vocabulary", event.getVocabularyIri(), "cached", false) ); } } diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java index 5d133df7f..23b72777c 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDaoTest.java @@ -372,7 +372,7 @@ void persistPublishesAssetPersistEvent() { transactional(() -> sut.persist(voc)); final ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); - verify(eventPublisher).publishEvent(captor.capture()); + verify(eventPublisher, atLeastOnce()).publishEvent(captor.capture()); final Optional evt = captor.getAllValues().stream() .filter(AssetPersistEvent.class::isInstance) .map(AssetPersistEvent.class::cast).findFirst(); @@ -767,7 +767,7 @@ void removePublishesEventAndDropsGraph() { VocabularyWillBeRemovedEvent event = eventCaptor.getValue(); assertNotNull(event); - assertEquals(event.getVocabulary(), vocabulary.getUri()); + assertEquals(event.getVocabularyIri(), vocabulary.getUri()); assertFalse(em.createNativeQuery("ASK WHERE{ GRAPH ?vocabulary { ?s ?p ?o }}", Boolean.class) .setParameter("vocabulary", vocabulary.getUri()) diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java index 123a15243..f62672c2a 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java @@ -21,6 +21,7 @@ import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.validation.ValidationResult; +import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -35,7 +36,9 @@ import static cz.cvut.kbss.termit.util.throttle.TestFutureRunner.runFuture; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.anyCollection; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; @@ -62,43 +65,42 @@ void setUp() { vocabulary = Generator.generateUri(); Term term = Generator.generateTermWithId(vocabulary); - validationResult = new ValidationResult() - .setTermUri(term.getUri()); + validationResult = new ValidationResult().setTermUri(term.getUri()); } @Test void invokesInternalValidatorWhenNoResultsAreCached() throws Exception { final List results = Collections.singletonList(validationResult); - when(validator.runValidation(anyCollection())).thenReturn(results); + when(validator.validate(any(), anyCollection())).thenReturn(ThrottledFuture.done(results)); final Set vocabularies = Collections.singleton(vocabulary); final Collection result = runFuture(sut.validate(vocabulary, vocabularies)); assertEquals(results, result); - verify(validator).runValidation(vocabularies); + verify(validator).validate(vocabulary, vocabularies); } @Test void returnsCachedResultsWhenArgumentsMatch() throws Exception { final List results = Collections.singletonList(validationResult); - when(validator.runValidation(anyCollection())).thenReturn(results); + when(validator.validate(any(), anyCollection())).thenReturn(ThrottledFuture.done(results)); final Set vocabularies = Collections.singleton(vocabulary); final Collection resultOne = runFuture(sut.validate(vocabulary, vocabularies)); - verify(validator).runValidation(vocabularies); + verify(validator).validate(vocabulary, vocabularies); final Collection resultTwo = runFuture(sut.validate(vocabulary, vocabularies)); - assertEquals(resultOne, resultTwo); - assertSame(results, resultOne); verifyNoMoreInteractions(validator); + assertIterableEquals(resultOne, resultTwo); + assertSame(results, resultOne); } @Test void evictCacheClearsCachedValidationResults() throws Exception { final List results = Collections.singletonList(validationResult); - when(validator.runValidation(anyCollection())).thenReturn(results); + when(validator.validate(any(), anyCollection())).thenReturn(ThrottledFuture.done(results)); final Set vocabularies = Collections.singleton(vocabulary); final Collection resultOne = runFuture(sut.validate(vocabulary, vocabularies)); - verify(validator).runValidation(vocabularies); + verify(validator).validate(vocabulary, vocabularies); sut.evictVocabularyCache(new VocabularyContentModifiedEvent(this, vocabulary)); final Collection resultTwo = runFuture(sut.validate(vocabulary, vocabularies)); - verify(validator, times(2)).runValidation(vocabularies); + verify(validator, times(2)).validate(vocabulary, vocabularies); assertEquals(resultOne, resultTwo); assertSame(results, resultOne); } diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java index e18edd119..93acdeea0 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java @@ -74,6 +74,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.inOrder; @@ -275,7 +276,7 @@ void updateAccessControlLevelRetrievesACLForVocabularyAndUpdatesSpecifiedRecord( @Test void persistCreatesAccessControlListAndSetsItOnVocabularyInstance() { final AccessControlList acl = Generator.generateAccessControlList(true); - final Vocabulary toPersist = Generator.generateVocabulary(); + final Vocabulary toPersist = Generator.generateVocabularyWithId(); when(aclService.createFor(toPersist)).thenReturn(acl); sut.persist(toPersist); @@ -379,9 +380,10 @@ void importNewVocabularyPublishesVocabularyCreatedEvent() { sut.importVocabulary(false, fileToImport); final ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); - verify(eventPublisher).publishEvent(captor.capture()); - assertInstanceOf(VocabularyCreatedEvent.class, captor.getValue()); - assertEquals(persisted, captor.getValue().getSource()); + verify(eventPublisher, atLeastOnce()).publishEvent(captor.capture()); + Optional event = captor.getAllValues().stream().filter(e -> e instanceof VocabularyCreatedEvent).map(e->(VocabularyCreatedEvent)e).findAny(); + assertTrue(event.isPresent()); + assertEquals(persisted.getUri(), event.get().getVocabularyIri()); } @Test From 4b303a57728021fb59e4f0395780d0368a40114a Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 5 Sep 2024 15:01:49 +0200 Subject: [PATCH 098/150] [Performance #287] Docs for throttle related classes --- .../cz/cvut/kbss/termit/util/Constants.java | 7 ++ .../util/longrunning/LongRunningTask.java | 21 ++++- .../longrunning/LongRunningTaskRegister.java | 9 +++ ...va => SynchronousTransactionExecutor.java} | 4 +- .../termit/util/throttle/ThrottleAspect.java | 77 ++++++++++++++----- .../termit/util/throttle/ThrottledFuture.java | 4 +- .../util/throttle/ThrottleAspectTest.java | 4 +- 7 files changed, 100 insertions(+), 26 deletions(-) rename src/main/java/cz/cvut/kbss/termit/util/throttle/{TransactionExecutor.java => SynchronousTransactionExecutor.java} (76%) diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index 32f309824..b457870a6 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -18,6 +18,7 @@ package cz.cvut.kbss.termit.util; import cz.cvut.kbss.jopa.vocabulary.SKOS; +import cz.cvut.kbss.termit.util.throttle.ThrottleAspect; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -57,6 +58,12 @@ public class Constants { */ public static final Duration THROTTLE_THRESHOLD = Duration.ofSeconds(10); + /** + * After how much time, should complete futures be discarded. + * @see ThrottleAspect#clearOldFutures() + */ + public static final Duration THROTTLE_DISCARD_THRESHOLD = Duration.ofMinutes(1); + /** * The amount of millis used as timeout for async REST tasks (REST controllers returning Callable): * 5 minutes diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java index 724541dbf..f5c70bba2 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java @@ -1,14 +1,31 @@ package cz.cvut.kbss.termit.util.longrunning; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.time.Instant; +import java.util.Optional; +/** + * An asynchronously running task that is expected to run for some time. + */ public interface LongRunningTask { + /** + * @return true when the task is being actively executed, false otherwise. + */ boolean isRunning(); + + /** + * @return true when the task has finished, false otherwise. + * Returns true regardless of whether the task succeeded. + */ boolean isCompleted(); - @Nullable - Instant runningSince(); + /** + * @return a timestamp of the task execution start, + * or empty if the task execution has not yet started. + */ + @NotNull + Optional runningSince(); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java index 5fcd7f644..0838df503 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java @@ -3,8 +3,17 @@ import org.jetbrains.annotations.NotNull; import java.util.Collection; +import java.util.concurrent.ConcurrentSkipListSet; +/** + * An object that will schedule a long-running tasks + * @see LongRunningTask + */ public interface LongRunningTaskRegister { + + /** + * @return pending and currently running tasks + */ @NotNull Collection getTasks(); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/TransactionExecutor.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java similarity index 76% rename from src/main/java/cz/cvut/kbss/termit/util/throttle/TransactionExecutor.java rename to src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java index e155db1f4..b6b35dabf 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/TransactionExecutor.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java @@ -7,12 +7,12 @@ import java.util.concurrent.Executor; /** - * Executes the runnable in a transaction + * Executes the runnable in a transaction synchronously. * * @see Transactional */ @Component -public class TransactionExecutor implements Executor { +public class SynchronousTransactionExecutor implements Executor { @Transactional @Override diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index 2755d9558..a5eef79e5 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -3,6 +3,7 @@ import cz.cvut.kbss.termit.TermItApplication; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.ThrottleAspectException; +import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Pair; import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskRegister; @@ -33,6 +34,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.Arrays; import java.util.Collection; @@ -45,17 +47,18 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executor; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; +import static cz.cvut.kbss.termit.util.Constants.THROTTLE_DISCARD_THRESHOLD; import static cz.cvut.kbss.termit.util.Constants.THROTTLE_THRESHOLD; import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_SINGLETON; /** + * @see Throttle * @implNote The aspect is configured in {@code spring-aop.xml}, this uses Spring AOP instead of AspectJ. */ @Order @@ -67,44 +70,67 @@ public class ThrottleAspect implements LongRunningTaskRegister { private static final Logger LOG = LoggerFactory.getLogger(ThrottleAspect.class); /** - * group, identifier -> future + *

    Throttled futures are returned as results of method calls.

    + *

    Tasks inside them can be replaced by a newer ones allowing + * to merge multiple (throttled) method calls into a single one while always executing the newest one possible.

    + *

    A task inside a throttled future represents + * a heavy/long-running task acquired from the body of an throttled method

    * - * @implSpec Synchronize in the field definition order before modification + * @implSpec Synchronize in the field declaration order before modification */ - private final Map> throttledFutures; + private final Map<@NotNull Identifier, @NotNull ThrottledFuture> throttledFutures; /** - * @implSpec Synchronize in the field definition order before modification + * The last run is updated every time a task is finished. + * @implSpec Synchronize in the field declaration order before modification */ - private final Map lastRun; + private final Map<@NotNull Identifier, @NotNull Instant> lastRun; /** - * group, identifier -> future + * Scheduled futures are returned from {@link #taskScheduler}. + * Futures are completed by execution of tasks created in {@link #createRunnableToSchedule}. * - * @implSpec Synchronize in the field definition order before modification + * @implSpec Synchronize in the field declaration order before modification */ - private final NavigableMap> scheduledFutures; + private final NavigableMap> scheduledFutures; /** - * thread safe set holding identifiers of threads - * currently executing a throttled task + * Thread safe set holding identifiers of threads that are + * currently executing a throttled task. */ private final Set throttledThreads = ConcurrentHashMap.newKeySet(); + /** + * Parser for Spring Expression Language + */ private final ExpressionParser parser = new SpelExpressionParser(); private final TaskScheduler taskScheduler; + /** + * A base context for evaluation of SpEL expressions + */ private final StandardEvaluationContext standardEvaluationContext; + /** + * Used for acquiring {@link #lastRun} timestamps. + * @implNote for testing purposes + */ private final Clock clock; - private final TransactionExecutor transactionExecutor; + /** + * Wrapper for executions in a transaction context + */ + private final SynchronousTransactionExecutor transactionExecutor; + /** + * A timestamp of the last time maps were cleaned. + * @see #clearOldFutures() + */ private final @NotNull AtomicReference lastClear; @Autowired - public ThrottleAspect(@Qualifier("threadPoolTaskScheduler") TaskScheduler taskScheduler, TransactionExecutor transactionExecutor) { + public ThrottleAspect(@Qualifier("threadPoolTaskScheduler") TaskScheduler taskScheduler, SynchronousTransactionExecutor transactionExecutor) { this.taskScheduler = taskScheduler; this.transactionExecutor = transactionExecutor; throttledFutures = new HashMap<>(); @@ -118,7 +144,7 @@ public ThrottleAspect(@Qualifier("threadPoolTaskScheduler") TaskScheduler taskSc protected ThrottleAspect(Map> throttledFutures, Map lastRun, NavigableMap> scheduledFutures, TaskScheduler taskScheduler, - Clock clock, TransactionExecutor transactionExecutor) { + Clock clock, SynchronousTransactionExecutor transactionExecutor) { this.throttledFutures = throttledFutures; this.lastRun = lastRun; this.scheduledFutures = scheduledFutures; @@ -346,23 +372,28 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id }; } + /** + * Discards futures from {@link #throttledFutures}, {@link #lastRun} and {@link #scheduledFutures} maps. + *

    Every completed future for which a {@link Constants#THROTTLE_DISCARD_THRESHOLD} expired is discarded.

    + * @see #isThresholdExpired(Identifier) + */ private void clearOldFutures() { // if the last clear was performed less than a threshold ago, skip it for now Instant last = lastClear.get(); - if (last.isAfter(Instant.now(clock).minus(THROTTLE_THRESHOLD))) { + if (last.isAfter(Instant.now(clock).minus(THROTTLE_THRESHOLD).minus(THROTTLE_DISCARD_THRESHOLD))) { return; } if (!lastClear.compareAndSet(last, Instant.now(clock))) { return; } - synchronized (throttledFutures) { + synchronized (throttledFutures) { // synchronize in the filed declaration order synchronized (lastRun) { synchronized (scheduledFutures) { Stream.of(throttledFutures.keySet().stream(), scheduledFutures.keySet().stream(), lastRun.keySet() .stream()) .flatMap(s -> s).distinct().toList() // ensures safe modification of maps .forEach(identifier -> { - if (isThresholdExpired(identifier)) { + if (isThresholdExpiredByMoreThan(identifier, THROTTLE_DISCARD_THRESHOLD)) { Optional.ofNullable(throttledFutures.get(identifier)).ifPresent(throttled -> { if (throttled.isDone() || throttled.isCancelled()) { throttledFutures.remove(identifier); @@ -381,6 +412,16 @@ private void clearOldFutures() { } } + /** + * @param identifier of the task + * @param duration to add to the throttle threshold + * @return Whether the last time when a task with specified {@code identifier} run + * is older than ({@link Constants#THROTTLE_THRESHOLD} + {@code duration}) + */ + private boolean isThresholdExpiredByMoreThan(Identifier identifier, Duration duration) { + return lastRun.getOrDefault(identifier, Instant.MAX).isBefore(Instant.now(clock).minus(THROTTLE_THRESHOLD).minus(duration)); + } + /** * @return Whether the time when the identifier last run is older than the threshold, * true when the task had never run @@ -406,7 +447,7 @@ private void cancelWithHigherGroup(Identifier throttleAnnotation) { if (throttleAnnotation.getGroup().isBlank()) { return; } - synchronized (throttledFutures) { + synchronized (throttledFutures) { // synchronize in the filed declaration order synchronized (scheduledFutures) { // look for any futures with higher group // cancel them and remove from maps diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index de2f28d8f..297754ac7 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -190,7 +190,7 @@ public boolean isCompleted() { } @Override - public @Nullable Instant runningSince() { - return completingSince; + public @NotNull Optional runningSince() { + return Optional.ofNullable(completingSince); } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index 5a99e1a82..5931f74db 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -54,7 +54,7 @@ class ThrottleAspectTest { TaskScheduler taskScheduler; - TransactionExecutor transactionExecutor; + SynchronousTransactionExecutor transactionExecutor; OrderedMap taskSchedulerTasks; @@ -141,7 +141,7 @@ void beforeEach() throws Throwable { Clock mockedClock = mock(Clock.class); when(mockedClock.instant()).then(invocation -> getInstant()); - transactionExecutor = mock(TransactionExecutor.class); + transactionExecutor = mock(SynchronousTransactionExecutor.class); doAnswer(invocation -> { invocation.getArgument(0, Runnable.class).run(); return null; From b93a36f821665d2ab00d7056896e1027eee0eb94 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Fri, 6 Sep 2024 14:35:05 +0200 Subject: [PATCH 099/150] [Performance #287] Docs for throttle related classes, tests description and further throttle testing --- doc/throttle-debounce.png | Bin 8278 -> 76179 bytes .../termit/persistence/dao/VocabularyDao.java | 9 +- .../service/business/VocabularyService.java | 5 +- .../VocabularyRepositoryService.java | 4 +- .../util/longrunning/LongRunningTask.java | 11 +- ...chableFuture.java => CacheableFuture.java} | 4 +- .../termit/util/throttle/ChainableFuture.java | 16 + .../kbss/termit/util/throttle/Throttle.java | 20 +- .../termit/util/throttle/ThrottleAspect.java | 54 +- .../util/throttle/ThrottleGroupProvider.java | 6 +- .../termit/util/throttle/ThrottledFuture.java | 104 ++-- .../websocket/VocabularySocketController.java | 17 +- .../util/throttle/MockedMethodSignature.java | 10 +- .../termit/util/throttle/MockedThrottle.java | 10 +- .../util/throttle/ThrottleAspectTest.java | 516 ++++++++++++++++-- .../util/throttle/ThrottledFutureTest.java | 118 ++++ 16 files changed, 761 insertions(+), 143 deletions(-) rename src/main/java/cz/cvut/kbss/termit/util/throttle/{CachableFuture.java => CacheableFuture.java} (89%) create mode 100644 src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java create mode 100644 src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java diff --git a/doc/throttle-debounce.png b/doc/throttle-debounce.png index 3b00e7b5ff4bae459bced5cdeb2510a9a92550a9..9d26208a794d43f49a8e744c11140e502bf0405a 100644 GIT binary patch literal 76179 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�h?1gAK94&Ps>iqbhtZxq2QoydC9b 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 G=sy5Y97*p0 diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index dfe939163..ebaa0d5b3 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -46,9 +46,7 @@ import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; -import cz.cvut.kbss.termit.util.throttle.CachableFuture; -import cz.cvut.kbss.termit.util.throttle.Throttle; -import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; +import cz.cvut.kbss.termit.util.throttle.CacheableFuture; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -62,14 +60,11 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.stream.Stream; import static cz.cvut.kbss.termit.util.Constants.DEFAULT_PAGE_SIZE; import static cz.cvut.kbss.termit.util.Constants.SKOS_CONCEPT_MATCH_RELATIONSHIPS; @@ -365,7 +360,7 @@ public void refreshLastModified(RefreshLastModifiedEvent event) { } @Transactional - public CachableFuture> validateContents(URI vocabulary) { + public CacheableFuture> validateContents(URI vocabulary) { final VocabularyContentValidator validator = context.getBean(VocabularyContentValidator.class); final Collection importClosure = getTransitivelyImportedVocabularies(vocabulary); importClosure.add(vocabulary); diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 7376fa50c..2eebeff0c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -46,13 +46,12 @@ import cz.cvut.kbss.termit.util.TypeAwareClasspathResource; import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource; import cz.cvut.kbss.termit.util.TypeAwareResource; -import cz.cvut.kbss.termit.util.throttle.CachableFuture; +import cz.cvut.kbss.termit.util.throttle.CacheableFuture; import cz.cvut.kbss.termit.util.throttle.Throttle; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; -import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.context.annotation.Lazy; @@ -358,7 +357,7 @@ public void remove(Vocabulary asset) { * * @param vocabulary Vocabulary to validate */ - public CachableFuture> validateContents(URI vocabulary) { + public CacheableFuture> validateContents(URI vocabulary) { return repositoryService.validateContents(vocabulary); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index e8a7fab7b..0f8fede41 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -41,7 +41,7 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Utils; -import cz.cvut.kbss.termit.util.throttle.CachableFuture; +import cz.cvut.kbss.termit.util.throttle.CacheableFuture; import cz.cvut.kbss.termit.workspace.EditableVocabularies; import jakarta.validation.Validator; import org.apache.tika.Tika; @@ -320,7 +320,7 @@ private void ensureNoTermRelationsExists(Vocabulary vocabulary) throws AssetRemo } } - public CachableFuture> validateContents(URI vocabulary) { + public CacheableFuture> validateContents(URI vocabulary) { return vocabularyDao.validateContents(vocabulary); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java index f5c70bba2..959b5c13f 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java @@ -17,10 +17,15 @@ public interface LongRunningTask { boolean isRunning(); /** - * @return true when the task has finished, false otherwise. - * Returns true regardless of whether the task succeeded. + * Returns {@code true} if this task completed. + * + * Completion may be due to normal termination, an exception, or + * cancellation -- in all of these cases, this method will return + * {@code true}. + * + * @return {@code true} if this task completed */ - boolean isCompleted(); + boolean isDone(); /** * @return a timestamp of the task execution start, diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java similarity index 89% rename from src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java rename to src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java index 26b0b29ac..74b2ff558 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/CachableFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java @@ -11,7 +11,7 @@ * A future which can provide a cached result before its completion. * @see Future */ -public interface CachableFuture extends Future { +public interface CacheableFuture extends ChainableFuture { /** * @return the cached result when available @@ -24,7 +24,7 @@ public interface CachableFuture extends Future { * @param cachedResult the result to set, or null to clear the cache * @return self */ - CachableFuture setCachedResult(@Nullable final T cachedResult); + CacheableFuture setCachedResult(@Nullable final T cachedResult); /** * @return the future result if it is available, cached result otherwise. diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java new file mode 100644 index 000000000..2696ad217 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java @@ -0,0 +1,16 @@ +package cz.cvut.kbss.termit.util.throttle; + +import java.util.concurrent.Future; +import java.util.function.Consumer; + +public interface ChainableFuture extends Future { + + /** + * Executes this action once the future is completed normally. + * Action is not executed on exceptional completion. + *

    + * If the future is already completed, action is executed synchronously. + * @param action action to be executed + */ + void then(Consumer action); +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java index 87c72ea96..f0fd2422c 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java @@ -13,25 +13,27 @@ /** * Indicates that calls to this method will be throttled & debounced. *

    - * Body of the annotated method will be executed on the first call of the method, + * The task created from the method will be executed on the first call of the method, * then every next call which comes earlier than {@link Constants#THROTTLE_THRESHOLD} * will return a pending future which might be resolved by a newer call. * Future will be resolved once per {@link Constants#THROTTLE_THRESHOLD} (+ duration to execute the future). *

    - * + * *

    * Every annotated method should be tested for throttling to ensure it has the desired effect. *

    * Method can't use any parameters that are part of persistent context as the method will be executed on separated thread, * objects need to be re-requested.
    * Call to this method cannot be part of an existing transaction. + * If {@link org.springframework.transaction.annotation.Transactional @Transactional} is present with this annotation, + * new transaction is created for the task execution. *

    * Available only for methods returning {@code void}, {@link Void} and {@link ThrottledFuture}, * method signature may be {@link Future}, * or another type assignable from {@link ThrottledFuture}, * but the returned concrete object has to be {@link ThrottledFuture}, method call will throw otherwise! *

    - * Whole body of method with {@code void} or {@link Void} return types will be considered as task to be executed later. + * Whole body of method with {@code void} or {@link Void} return types will be considered as task which will be executed later. * In case of {@link Future} return type, only task in returned {@link ThrottledFuture} is throttled, * meaning that actual body of the method will be executed every call. *

    @@ -43,7 +45,16 @@ *

    
      *  {@code @}Throttle(value = "{#paramObj, #anotherParam}")
      *  public Future<String> myFunction(Object paramObj, Object anotherParam) {
    - *      return ThrottledFuture.of(() -> doStuff());
    + *      // this will execute on every call as the return type is future
    + *      LOG.trace("my function called");
    + *      return ThrottledFuture.of(() -> doStuff()); // doStuff() will be throttled
    + *  }
    + * 
    + *
    
    + *  {@code @}Throttle(value = "{#paramObj, #anotherParam}")
    + *  public void myFunction(Object paramObj, Object anotherParam) {
    + *      // whole method body will be throttled, as return type is not future
    + *      LOG.trace("my function called");
      *  }
      * 
    * @@ -83,6 +94,7 @@ * -> task A is canceled as the task C has lower group than A * * Blank string disables any group processing. + * @see String#compareTo(String) */ @NotNull String group() default ""; } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index a5eef79e5..ef61b8f6d 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -78,21 +78,22 @@ public class ThrottleAspect implements LongRunningTaskRegister { * * @implSpec Synchronize in the field declaration order before modification */ - private final Map<@NotNull Identifier, @NotNull ThrottledFuture> throttledFutures; + private final Map> throttledFutures; /** * The last run is updated every time a task is finished. * @implSpec Synchronize in the field declaration order before modification */ - private final Map<@NotNull Identifier, @NotNull Instant> lastRun; + private final Map lastRun; /** * Scheduled futures are returned from {@link #taskScheduler}. * Futures are completed by execution of tasks created in {@link #createRunnableToSchedule}. + * Records about them are used for their cancellation in case of debouncing. * * @implSpec Synchronize in the field declaration order before modification */ - private final NavigableMap> scheduledFutures; + private final NavigableMap> scheduledFutures; /** * Thread safe set holding identifiers of threads that are @@ -127,7 +128,7 @@ public class ThrottleAspect implements LongRunningTaskRegister { * A timestamp of the last time maps were cleaned. * @see #clearOldFutures() */ - private final @NotNull AtomicReference lastClear; + private final @NotNull AtomicReference<@NotNull Instant> lastClear; @Autowired public ThrottleAspect(@Qualifier("threadPoolTaskScheduler") TaskScheduler taskScheduler, SynchronousTransactionExecutor transactionExecutor) { @@ -141,6 +142,9 @@ public ThrottleAspect(@Qualifier("threadPoolTaskScheduler") TaskScheduler taskSc lastClear = new AtomicReference<>(Instant.now(clock)); } + /** + * Constructor for testing environment + */ protected ThrottleAspect(Map> throttledFutures, Map lastRun, NavigableMap> scheduledFutures, TaskScheduler taskScheduler, @@ -211,7 +215,7 @@ private static StandardEvaluationContext makeDefaultContext() { if (lowerEntry != null) { final Future lowerFuture = lowerEntry.getValue(); boolean hasGroupPrefix = identifier.hasGroupPrefix(lowerEntry.getKey().getGroup()); - if (hasGroupPrefix && !lowerFuture.isDone() && !lowerFuture.isCancelled()) { + if (hasGroupPrefix && !lowerFuture.isDone()) { LOG.trace("Throttling canceled due to scheduled lower task '{}'", lowerEntry.getKey()); return ThrottledFuture.canceled(); } @@ -224,10 +228,13 @@ private static StandardEvaluationContext makeDefaultContext() { // if there is a scheduled task and this throttled instance was executed in the last THROTTLE_THRESHOLD // cancel the scheduled task // -> the execution is further delayed - Future oldFuture = scheduledFutures.get(identifier); + Future oldScheduledFuture = scheduledFutures.get(identifier); boolean throttleExpired = isThresholdExpired(identifier); - if (oldFuture != null && !throttleExpired) { - oldFuture.cancel(false); + if (oldScheduledFuture != null && !throttleExpired) { + oldScheduledFuture.cancel(false); + synchronized (scheduledFutures) { + scheduledFutures.remove(identifier); + } } // acquire a throttled future from a map, or make a new one @@ -236,19 +243,20 @@ private static StandardEvaluationContext makeDefaultContext() { Pair> pair = getFutureTask(joinPoint, identifier, oldThrottledFuture); Runnable task = pair.getFirst(); ThrottledFuture future = pair.getSecond(); - // update the throttled future in the map + // update the throttled future in the map, it might be just the same future, but it might be a new one synchronized (throttledFutures) { throttledFutures.put(identifier, future); } Object result = resultVoidOrFuture(signature, future); - if (future.isCompleted() || future.isRunning()) { + if (future.isDone() || future.isRunning()) { return result; } - if (oldFuture == null || oldFuture.isDone() || oldFuture.isCancelled()) { - schedule(identifier, task, throttleExpired); + if (oldScheduledFuture == null || oldThrottledFuture != future || oldScheduledFuture.isDone()) { + boolean oldFutureIsDone = oldScheduledFuture == null || oldScheduledFuture.isDone(); + schedule(identifier, task, throttleExpired && oldFutureIsDone); } return result; @@ -308,6 +316,7 @@ private Pair> getFutureTask(@NotNull Proceedin if (throttledMethodFuture.isDone()) { throttledFuture = (ThrottledFuture) throttledMethodFuture; } else { + // transfer the newer task from methodFuture -> to the (old) throttled future throttledFuture = ((ThrottledFuture) throttledMethodFuture).transfer(throttledFuture); } } else { @@ -321,7 +330,7 @@ private Pair> getFutureTask(@NotNull Proceedin // exception happened inside throttled method throw new TermItException(e); } - }); + }, List.of()); } final boolean withTransaction = methodSignature.getMethod() != null && methodSignature.getMethod() @@ -337,7 +346,7 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id boolean withTransaction) { final Supplier securityContext = SecurityContextHolder.getDeferredContext(); return () -> { - if (throttledFuture.isCancelled() || throttledFuture.isDone()) { + if (throttledFuture.isDone()) { return; } // mark the thread as throttled @@ -395,12 +404,12 @@ private void clearOldFutures() { .forEach(identifier -> { if (isThresholdExpiredByMoreThan(identifier, THROTTLE_DISCARD_THRESHOLD)) { Optional.ofNullable(throttledFutures.get(identifier)).ifPresent(throttled -> { - if (throttled.isDone() || throttled.isCancelled()) { + if (throttled.isDone()) { throttledFutures.remove(identifier); } }); Optional.ofNullable(scheduledFutures.get(identifier)).ifPresent(scheduled -> { - if (scheduled.isDone() || scheduled.isCancelled()) { + if (scheduled.isDone()) { scheduledFutures.remove(identifier); } }); @@ -462,11 +471,10 @@ private void cancelWithHigherGroup(Identifier throttleAnnotation) { higherFuture = scheduledFutures.get(higherKey); higherFuture.cancel(false); final ThrottledFuture throttledFuture = throttledFutures.get(higherKey); - if (throttledFuture != null) { - throttledFuture.cancel(false); - if (throttledFuture.isCancelled()) { - throttledFutures.remove(higherKey); - } + + // cancels future if it's not null (should not be) and removes it from map if it was canceled + if (throttledFuture != null && throttledFuture.cancel(false)) { + throttledFutures.remove(higherKey); } scheduledFutures.remove(higherKey); @@ -555,8 +563,8 @@ public String getIdentifier() { return this.getSecond(); } - public boolean hasGroupPrefix(String group) { - return this.getGroup().indexOf(group) == 0; + public boolean hasGroupPrefix(@NotNull String group) { + return this.getGroup().indexOf(group) == 0 && !this.getGroup().isBlank() && !group.isBlank(); } @Override diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java index 44ce739f0..c832ef463 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleGroupProvider.java @@ -2,7 +2,11 @@ import java.net.URI; -@SuppressWarnings("unused") +/** + * Provides static methods allowing construction of dynamic group identifiers + * used in {@link Throttle @Throttle} annotations. + */ +@SuppressWarnings("unused") // it is used from SpEL expressions public class ThrottleGroupProvider { private ThrottleGroupProvider() { diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index 297754ac7..2852393cf 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -1,29 +1,35 @@ package cz.cvut.kbss.termit.util.throttle; +import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; import java.util.function.Supplier; -public class ThrottledFuture implements CachableFuture, LongRunningTask { +public class ThrottledFuture implements CacheableFuture, LongRunningTask { private final ReentrantLock lock = new ReentrantLock(); - private T cachedResult = null; + private @Nullable T cachedResult = null; private final CompletableFuture future; private @Nullable Supplier task; + private final List> onCompletion = new ArrayList<>(); + /** * Access only with acquired {@link #lock} */ @@ -116,16 +122,19 @@ public T get(long timeout, @NotNull TimeUnit unit) * @return If the current task is already running, was canceled or already completed, returns a new future for the given task. * Otherwise, replaces the current task and returns self. */ - protected ThrottledFuture update(Supplier task) { + protected ThrottledFuture update(Supplier task, List> onCompletion) { + boolean locked = false; try { - boolean locked = lock.tryLock(); - if (!locked || isRunning() || isCompleted()) { - return ThrottledFuture.of(task); + locked = lock.tryLock(); + ThrottledFuture updatedFuture = this; + if (!locked || isRunning() || isDone()) { + updatedFuture = ThrottledFuture.of(task); } - this.task = task; - return this; + updatedFuture.task = task; + updatedFuture.onCompletion.addAll(onCompletion); + return updatedFuture; } finally { - if (lock.isHeldByCurrentThread()) { + if (locked) { lock.unlock(); } } @@ -141,56 +150,81 @@ protected ThrottledFuture update(Supplier task) { * New future when the target is being executed, was canceled or completed. */ protected ThrottledFuture transfer(ThrottledFuture target) { + boolean locked = false; try { - boolean locked = lock.tryLock(); - if (!locked || isRunning() || isCompleted()) { + locked = lock.tryLock(); + if (!locked || isRunning() || isDone()) { return target; } - ThrottledFuture result = target.update(this.task); + ThrottledFuture result = target.update(this.task, this.onCompletion); this.task = null; + this.onCompletion.clear(); return result; } finally { - if (lock.isHeldByCurrentThread()) { + if (locked) { lock.unlock(); } } } protected void run() { - boolean locked; - do { - locked = lock.tryLock(); - if (isRunning() || isCompleted()) { - return; - } else if (!locked) { - Thread.yield(); + boolean locked = false; + try { + do { + locked = lock.tryLock(); + if (isRunning() || isDone()) { + return; + } else if (!locked) { + Thread.yield(); + } + } while (!locked); + completingSince = Utils.timestamp(); + + T result = null; + if (task != null) { + result = task.get(); + final T finalResult = result; + onCompletion.forEach(c -> c.accept(finalResult)); + } + future.complete(result); + } finally { + if (locked) { + lock.unlock(); } - } while (!locked); - completingSince = Utils.timestamp(); - - if (task != null) { - future.complete(task.get()); - } else { - future.complete(null); } } @Override public boolean isRunning() { - return completingSince != null; + return completingSince != null && !isDone(); } - /** - * @return true if the future is done or canceled, false otherwise - */ @Override - public boolean isCompleted() { - return isDone() || isCancelled(); + public @NotNull Optional runningSince() { + return Optional.ofNullable(completingSince); } @Override - public @NotNull Optional runningSince() { - return Optional.ofNullable(completingSince); + public void then(Consumer action) { + try { + lock.lock(); + if (future.isDone() && !future.isCancelled()) { + try { + action.accept(future.get()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TermItException(e); + } catch (ExecutionException e) { + throw new TermItException(e); + } + } else { + onCompletion.add(action); + } + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index 457229a1d..a02db3875 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -8,7 +8,7 @@ import cz.cvut.kbss.termit.service.business.VocabularyService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; -import cz.cvut.kbss.termit.util.throttle.CachableFuture; +import cz.cvut.kbss.termit.util.throttle.CacheableFuture; import org.jetbrains.annotations.NotNull; import org.springframework.context.event.EventListener; import org.springframework.messaging.MessageHeaders; @@ -51,16 +51,25 @@ public void validateVocabulary(@DestinationVariable String localName, final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); final Vocabulary vocabulary = vocabularyService.getReference(identifier); - final CachableFuture> future = vocabularyService.validateContents(vocabulary.getUri()); + final CacheableFuture> future = vocabularyService.validateContents(vocabulary.getUri()); - future.getNow().ifPresent(validationResults -> + future.getNow().ifPresentOrElse(validationResults -> sendToSession( DESTINATION_VOCABULARIES_VALIDATION, validationResults, Map.of("vocabulary", identifier, "cached", !future.isDone()), messageHeaders - )); + ), () -> + future.then(results -> sendToSession( + DESTINATION_VOCABULARIES_VALIDATION, + results, + Map.of("vocabulary", identifier, + "cached", false), + messageHeaders + )) + ); + } /** diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java index 7a60fa74b..310d99398 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedMethodSignature.java @@ -9,13 +9,15 @@ */ public class MockedMethodSignature implements MethodSignature { + private final String methodName; private Class returnType; private Class[] parameterTypes; private String[] parameterNames; - public MockedMethodSignature(Class returnType, Class[] parameterTypes, String[] parameterNames) { + public MockedMethodSignature(String methodName, Class returnType, Class[] parameterTypes, String[] parameterNames) { + this.methodName = methodName; this.returnType = returnType; this.parameterTypes = parameterTypes; this.parameterNames = parameterNames; @@ -52,17 +54,17 @@ public Class[] getExceptionTypes() { @Override public String toShortString() { - return "shortMethodSignatureString"; + return "shortMethodSignatureString" + methodName; } @Override public String toLongString() { - return "longMethodSignatureString"; + return "longMethodSignatureString" + methodName; } @Override public String getName() { - return "testingMethodName"; + return methodName; } @Override diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java index 43d25e967..1286c8c35 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java @@ -9,11 +9,11 @@ */ public class MockedThrottle implements Throttle { - private String value; + private @NotNull String value; - private String group; + private @NotNull String group; - public MockedThrottle(String value, String group) { + public MockedThrottle(@NotNull String value, @NotNull String group) { this.value = value; this.group = group; } @@ -33,11 +33,11 @@ public Class annotationType() { return Throttle.class; } - public void setValue(String value) { + public void setValue(@NotNull String value) { this.value = value; } - public void setGroup(String group) { + public void setGroup(@NotNull String group) { this.group = group; } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index 5931f74db..80a38955c 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -3,19 +3,25 @@ import com.vladsch.flexmark.util.collection.OrderedMap; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.ThrottleAspectException; +import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; import org.aspectj.lang.ProceedingJoinPoint; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.expression.spel.SpelParseException; import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.support.TaskUtils; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.transaction.annotation.Transactional; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.NavigableMap; import java.util.Optional; @@ -25,37 +31,66 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Supplier; +import java.util.stream.Stream; +import static cz.cvut.kbss.termit.util.Constants.THROTTLE_DISCARD_THRESHOLD; import static cz.cvut.kbss.termit.util.Constants.THROTTLE_THRESHOLD; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +//@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class ThrottleAspectTest { + /** + * Throttled futures from {@link #sut} + */ OrderedMap> throttledFutures; + /** + * Last run map from {@link #sut} + */ OrderedMap lastRun; + /** + * Scheduled futures from {@link #sut} + */ NavigableMap> scheduledFutures; + /** + * Mocked task scheduler. + * Does not execute tasks automatically, + * they need to be executed with {@link #executeScheduledTasks()} + * Tasks are wrapped into {@link FutureTask} and saved to {@link #taskSchedulerTasks}. + */ TaskScheduler taskScheduler; SynchronousTransactionExecutor transactionExecutor; + /** + * Tasks that were submitted to {@link #taskScheduler} + * @see #beforeEach() + */ OrderedMap taskSchedulerTasks; ThrottleAspect sut; @@ -66,16 +101,46 @@ class ThrottleAspectTest { MockedThrottle throttleC; + /** + * Default mock:
    + * return type: primitive {@code void}
    + * parameters: {@link Object Object paramA}, {@link Object Object paramB}
    + */ MockedMethodSignature signatureA; + /** + * Default mock:
    + * return type: wrapped {@link Void}
    + * parameters: {@link Map Map<String,String> paramName}
    + */ MockedMethodSignature signatureB; + /** + * Default mock:
    + * return type: primitive {@code void}
    + * parameters: {@link Object Object paramA}, {@link Object Object paramB}
    + */ MockedMethodSignature signatureC; + /** + * Default mock: returning {@code null} on proceed, + * method called with two {@link Object} arguments + * @see #signatureA + */ ProceedingJoinPoint joinPointA; + /** + * Default mock: returning {@code null} on proceed, + * method called with one parameter ({@link Map Map<String,String>} with two entries) + * @see #signatureB + */ ProceedingJoinPoint joinPointB; + /** + * Default mock: returning {@code null} on proceed, + * method called with two {@link Object} parameters + * @see #signatureC + */ ProceedingJoinPoint joinPointC; Clock clock = Clock.fixed(Instant.now(), ZoneId.of("UTC")); @@ -83,7 +148,7 @@ class ThrottleAspectTest { void mockA() throws Throwable { joinPointA = mock(ProceedingJoinPoint.class); when(joinPointA.proceed()).thenReturn(null); - signatureA = spy(new MockedMethodSignature(Void.TYPE, new Class[]{Object.class, Object.class}, new String[]{ + signatureA = spy(new MockedMethodSignature("methodA", Void.TYPE, new Class[]{Object.class, Object.class}, new String[]{ "paramA", "paramB"})); when(joinPointA.getSignature()).thenReturn(signatureA); when(joinPointA.getArgs()).thenReturn(new Object[]{new Object(), new Object()}); @@ -95,7 +160,7 @@ void mockA() throws Throwable { void mockB() throws Throwable { joinPointB = mock(ProceedingJoinPoint.class); when(joinPointB.proceed()).thenReturn(null); - signatureB = spy(new MockedMethodSignature(Void.class, new Class[]{Map.class}, new String[]{"paramName"})); + signatureB = spy(new MockedMethodSignature("methodB", Void.class, new Class[]{Map.class}, new String[]{"paramName"})); when(joinPointB.getSignature()).thenReturn(signatureB); when(joinPointB.getArgs()).thenReturn(new Object[]{Map.of("first", "firstValue", "second", "secondValue")}); @@ -107,8 +172,8 @@ void mockB() throws Throwable { void mockC() throws Throwable { joinPointC = mock(ProceedingJoinPoint.class); when(joinPointC.proceed()).thenReturn(null); - signatureC = spy(new MockedMethodSignature(Void.TYPE, new Class[]{Object.class, Object.class}, new String[]{ - "paramA", "paramB"})); + signatureC = spy(new MockedMethodSignature("methodC", Void.TYPE, new Class[]{Object.class, Object.class}, new String[]{ + "paramC", "paramD"})); when(joinPointC.getSignature()).thenReturn(signatureC); when(joinPointC.getArgs()).thenReturn(new Object[]{new Object(), new Object()}); when(joinPointC.getTarget()).thenReturn(this); @@ -141,15 +206,14 @@ void beforeEach() throws Throwable { Clock mockedClock = mock(Clock.class); when(mockedClock.instant()).then(invocation -> getInstant()); - transactionExecutor = mock(SynchronousTransactionExecutor.class); - doAnswer(invocation -> { - invocation.getArgument(0, Runnable.class).run(); - return null; - }).when(transactionExecutor).execute(any(Runnable.class)); + transactionExecutor = spy(SynchronousTransactionExecutor.class); sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock, transactionExecutor); } + /** + * @return current timestamp based on mocked {@link #clock} + */ Instant getInstant() { return clock.instant().truncatedTo(ChronoUnit.SECONDS); } @@ -162,22 +226,40 @@ void skipThreshold() { clock = Clock.fixed(clock.instant().plus(THROTTLE_THRESHOLD), ZoneId.of("UTC")); } + void skipDiscardThreshold() { + clock = Clock.fixed(clock.instant() + .plus(THROTTLE_DISCARD_THRESHOLD) + .plus(THROTTLE_THRESHOLD) + .plusSeconds(1), + ZoneId.of("UTC")); + } + + /** + * Executes all tasks in {@link #taskSchedulerTasks} and clears the map. + */ void executeScheduledTasks() { taskSchedulerTasks.forEach((runnable, instant) -> runnable.run()); taskSchedulerTasks.clear(); } + /** + * If a task was executed more than a threshold period before, it should NOT be debounced + */ @Test void firstCallAfterThresholdIsScheduledImmediately() throws Throwable { - sut.throttleMethodCall(joinPointA, throttleA); - executeScheduledTasks(); + sut.throttleMethodCall(joinPointA, throttleA); // first call of the method + executeScheduledTasks(); // simulate that the task was executed + // simulate that there was a delay before next call, + // and it was greater than threshold skipThreshold(); addSecond(); - final Instant expectedTime = getInstant(); - sut.throttleMethodCall(joinPointA, throttleA); + final Instant expectedTime = getInstant(); // note current time + sut.throttleMethodCall(joinPointA, throttleA); // second call of the method + // verify that the task from the second call was scheduled immediately + // because the last time, task was executed was before more than the threshold period assertEquals(1, taskSchedulerTasks.size()); assertEquals(expectedTime, taskSchedulerTasks.getValue(0)); } @@ -185,42 +267,42 @@ void firstCallAfterThresholdIsScheduledImmediately() throws Throwable { /** * Calling the annotated method three times * will execute it only once with the newest data. + * Task is scheduled by the first call. */ @Test void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { - final String[] params = new String[]{"param1", "param2", "param3", "param4", "param5", "param6"}; // define a future as the return type of the method - doReturn(Future.class).when(signatureA).getReturnType(); + signatureA.setReturnType(Future.class); - final Supplier methodTask = () -> "method result"; + final Supplier methodResult = () -> "method result"; final Supplier anotherMethodResult = () -> "another method result"; - final ThrottledFuture methodFuture = ThrottledFuture.of(methodTask); + final ThrottledFuture methodFuture = ThrottledFuture.of(methodResult); - // for each method call, make new future with "another method task" + // for each method call, make new future doAnswer(invocation -> ThrottledFuture.of(anotherMethodResult)).when(joinPointA).proceed(); final Instant firstCall = getInstant(); // simulate first call - when(joinPointA.getArgs()).thenReturn(new Object[]{params[0], params[1]}); sut.throttleMethodCall(joinPointA, throttleA); addSecond(); // simulate second call - when(joinPointA.getArgs()).thenReturn(new Object[]{params[2], params[3]}); - sut.throttleMethodCall(joinPointA, throttleA); + sut.throttleMethodCall(joinPointA, throttleA); // both tasks should return anotherMethodResult addSecond(); // change the return value of the method to the prepared future doReturn(methodFuture).when(joinPointA).proceed(); // simulate last call - when(joinPointA.getArgs()).thenReturn(new Object[]{params[4], params[5]}); - sut.throttleMethodCall(joinPointA, throttleA); + sut.throttleMethodCall(joinPointA, throttleA); // should return methodResult // there should be only a single scheduled future + // threshold was not reached and no task was executed, calls should be merged + // scheduled for immediate execution from the first call with the newest data from the last call assertEquals(1, scheduledFutures.size()); assertEquals(1, taskSchedulerTasks.size()); + assertEquals(1, throttledFutures.size()); final Instant scheduledAt = taskSchedulerTasks.getValue(0); final Runnable scheduledTask = taskSchedulerTasks.getKey(0); @@ -232,23 +314,24 @@ void threeImmediateCallsScheduleFirstCallWithLastTask() throws Throwable { final ThrottledFuture future = throttledFutures.getValue(0); assertNotNull(future); - // fulfill the future - scheduledTask.run(); + // perform task execution + executeScheduledTasks(); // the future should be completed assertTrue(future.isDone()); // check that the task in the future is from the last method call - assertEquals(methodTask.get(), future.get()); + assertEquals(methodResult.get(), future.get()); } /** * When method is called in the throttle interval - * call are merged and method will be executed only once. + * calls are merged and method will be executed only once. + * Ensures that both futures returned from method calls are same. */ @Test void callsInThrottleIntervalAreMerged() throws Throwable { final String[] params = new String[]{"param1", "param2", "param3", "param4", "param5", "param6"}; // define a future as the return type of the method - doReturn(Future.class).when(signatureA).getReturnType(); + signatureA.setReturnType(Future.class); // for each method call, make new future with "another method task" doAnswer(invocation -> new ThrottledFuture()).when(joinPointA).proceed(); @@ -269,80 +352,183 @@ void callsInThrottleIntervalAreMerged() throws Throwable { assertEquals(result1, result2); } - @SuppressWarnings("unchecked") + /** + * Within the threshold interval, when a task from first call is already executed, during a new call, + * new future is scheduled. + */ @Test - void schedulesNewFutureWhenTheOldOneIsCompleted() throws Throwable { - doReturn(Future.class).when(signatureA).getReturnType(); + @SuppressWarnings("unchecked") + void schedulesNewFutureWhenTheOldOneIsCompletedDuringThreshold() throws Throwable { + // set return type as future + signatureA.setReturnType(Future.class); + // return new throttled future on each method call when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(() -> "result")); - Future firstFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); - addSecond(); + // first call of the method + ThrottledFuture firstFuture = (ThrottledFuture) sut.throttleMethodCall(joinPointA, throttleA); + addSecond(); // changing time (but not more than the threshold) + + // verify that a future was returned assertNotNull(firstFuture); + // verify that the future is pending assertFalse(firstFuture.isDone()); assertFalse(firstFuture.isCancelled()); + assertFalse(firstFuture.isRunning()); + // verify that a single task was scheduled assertEquals(1, taskSchedulerTasks.size()); + // execute the task executeScheduledTasks(); + // verify that the task was completed assertTrue(firstFuture.isDone()); assertFalse(firstFuture.isCancelled()); + assertFalse(firstFuture.isRunning()); - Future secondFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); + // perform a second call, throttled interval was not reached + ThrottledFuture secondFuture = (ThrottledFuture) sut.throttleMethodCall(joinPointA, throttleA); addSecond(); + // verify returned second future assertNotNull(secondFuture); + + // verify that returned futures are not same + assertNotEquals(firstFuture, secondFuture); + + // it was not completed yet assertFalse(secondFuture.isDone()); assertFalse(secondFuture.isCancelled()); + assertFalse(secondFuture.isRunning()); + // verify a new future was scheduled assertEquals(1, scheduledFutures.size()); assertEquals(1, taskSchedulerTasks.size()); + // execute new task executeScheduledTasks(); + + // the new future was completed assertTrue(secondFuture.isDone()); assertFalse(secondFuture.isCancelled()); - - assertNotEquals(firstFuture, secondFuture); + assertFalse(secondFuture.isRunning()); } /** * Ensures that calling the annotated method even outside the threshold - * merges calls when no future was resolved. + * merges calls when no future was resolved yet (and task is not running). */ @SuppressWarnings("unchecked") @Test void callsAreMergedWhenCalledOutsideTheThresholdButNoFutureExecutedYet() throws Throwable { - doReturn(Future.class).when(signatureA).getReturnType(); + // change return type to future + signatureA.setReturnType(Future.class); + final String firstResult = "first result"; final String secondResult = "second result"; + + // on each method call return a new throttled future with firstResult when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(() -> firstResult)); + // first method call Future firstFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); + + // ensure that threshold was reached skipThreshold(); - skipThreshold(); + addSecond(); + // change method call result to throttled future with secondResult when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(() -> secondResult)); + + // second method call Future secondFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); + // verify that the returned future is not null and was not completed yet assertNotNull(firstFuture); assertFalse(firstFuture.isDone()); assertFalse(firstFuture.isCancelled()); - assertNotNull(secondFuture); - assertFalse(secondFuture.isDone()); - assertFalse(secondFuture.isCancelled()); - + // verify that calls were merged and returned futures are same assertEquals(firstFuture, secondFuture); + // only one task was scheduled assertEquals(1, scheduledFutures.size()); assertEquals(1, taskSchedulerTasks.size()); + executeScheduledTasks(); assertTrue(firstFuture.isDone()); assertFalse(firstFuture.isCancelled()); - // future resolved with the newest call + + // verify that the future was resolved with the newest call data assertEquals(secondResult, firstFuture.get()); - assertTrue(secondFuture.isDone()); - assertFalse(secondFuture.isCancelled()); - assertEquals(secondResult, secondFuture.get()); + assertTrue(firstFuture.isDone()); + assertFalse(firstFuture.isCancelled()); + assertEquals(secondResult, firstFuture.get()); + } + + /** + * When task is currently being executed and last execution did not happen in last threshold period, + * next call should not be executed immediately. + * That is because there is already same task running and so a new call should be debounced. + */ + @Test + void callToAMethodDuringTaskExecutionOutsideOfThresholdWillResolveToScheduleOfNewFuture() throws Throwable { + AtomicBoolean allowTaskToFinish = new AtomicBoolean(false); + AtomicBoolean taskRunning = new AtomicBoolean(false); + + // method return type is void, whole method body is considered as a task + when(joinPointA.proceed()).then(invocation -> { + // simulate long running task + taskRunning.set(true); + while (!allowTaskToFinish.get()) { + Thread.yield(); + } + return null; + }); + + // first method call + // there was no call before, which means the task should be scheduled for immediate execution + sut.throttleMethodCall(joinPointA, throttleA); + + Thread taskThread = new Thread(taskSchedulerTasks.getKey(0)); + + try { + // start long task execution + taskThread.start(); + + await("task execution start").atMost(Duration.ofSeconds(30)).untilTrue(taskRunning); + + assertEquals(1, throttledFutures.size()); + assertTrue(throttledFutures.getValue(0).isRunning()); + final Map.Entry immediateSchedule = taskSchedulerTasks.entrySet().getValue(0); + + assertNotNull(immediateSchedule); + // verify that the task was scheduled immediately + assertEquals(getInstant(), immediateSchedule.getValue()); + + // move time by a second + addSecond(); + + // this is second method call in the throttled threshold (time moved only by a second) + sut.throttleMethodCall(joinPointA, throttleA); + // task should not be scheduled for immediate execution + // verify a new task was scheduled + assertEquals(2, taskSchedulerTasks.size()); + + final Map.Entry scheduled = taskSchedulerTasks.entrySet().getValue(1); + assertNotEquals(immediateSchedule, scheduled); + + // the second task should be debounced by a throttle threshold + final Instant expectedSchedule = immediateSchedule.getValue().plusSeconds(1) // added second to the clock + .plus(THROTTLE_THRESHOLD); // should be debounced + assertEquals(expectedSchedule, scheduled.getValue()); + } finally { // ensure that the thread will be terminated in the test + allowTaskToFinish.set(true); + taskThread.join(60 * 1000); /* one minute, ensures that the test won't run indefinitely*/ + if (taskThread.isAlive()) { + taskThread.interrupt(); + fail("task thread thread interrupted due to timeout"); + } + } } @Test @@ -398,6 +584,142 @@ void immediatelyCancelsNewFutureWhenLowerGroupIsAlreadyScheduled() throws Throwa assertTrue(secondCall.isCancelled()); } + /** + * When a thread is executing a task from throttled method, and it reaches another throttled method, + * no further task should be scheduled and the throttled method should be executed synchronously. + */ + @Test + void callToThrottledMethodReturningVoidFromAlreadyThrottledThreadResultsInSynchronousExecution() throws Throwable { + AtomicLong threadId = new AtomicLong(-1); + + // prepare a simulated nested throttled method + when(joinPointB.proceed()).then(invocation -> { + threadId.set(Thread.currentThread().getId()); + return null; // void return type + }); + + // when method A is executed, call throttled method B + when(joinPointA.proceed()).then(invocation -> { + sut.throttleMethodCall(joinPointB, throttleB); + return null; // void return type + }); + + sut.throttleMethodCall(joinPointA, throttleA); + + // execute a single scheduled task + Thread runThread = new Thread(taskSchedulerTasks.getKey(0)); + runThread.start(); + + runThread.join(15 * 1000); + if (runThread.isAlive()) { + runThread.interrupt(); + fail("task thread thread interrupted due to timeout"); + } + + assertNotEquals(-1, threadId.get()); + assertEquals(runThread.getId(), threadId.get()); + } + + /** + * Same as {@link #callToThrottledMethodReturningVoidFromAlreadyThrottledThreadResultsInSynchronousExecution} + * but with method returning a future + */ + @Test + void callToThrottledMethodReturningFutureFromAlreadyThrottledThreadResultsInSynchronousExecution() throws Throwable { + AtomicLong threadId = new AtomicLong(-1); + + signatureA.setReturnType(Future.class); + signatureB.setReturnType(Future.class); + + // prepare a simulated nested throttled method + when(joinPointB.proceed()).then(invocation -> ThrottledFuture.of(()-> + threadId.set(Thread.currentThread().getId()) + )); + + // when method A is executed, call throttled method B + when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(() -> { + try { + sut.throttleMethodCall(joinPointB, throttleB); + } catch (Throwable t) { + fail(t); + } + })); + + sut.throttleMethodCall(joinPointA, throttleA); + + // execute a single scheduled task + Thread runThread = new Thread(taskSchedulerTasks.getKey(0)); + runThread.start(); + + runThread.join(15 * 1000); + if (runThread.isAlive()) { + runThread.interrupt(); + fail("task thread thread interrupted due to timeout"); + } + + assertNotEquals(-1, threadId.get()); + assertEquals(runThread.getId(), threadId.get()); + } + + /** + * When a throttled method is annotated with {@link Transactional @Transactional} + * the asynchronous task should be executed with {@link SynchronousTransactionExecutor} + * by a throttled thread. + */ + @Test + void taskFromMethodAnnotatedWithTransactionalIsExecutedWithTransactionExecutor() throws Throwable { + // simulates a method object with transactional annotation + when(signatureA.getMethod()).thenReturn(SynchronousTransactionExecutor.class.getDeclaredMethod("execute", Runnable.class)); + signatureA.setReturnType(Future.class); + Runnable task = () -> {}; + when(joinPointA.proceed()).thenReturn(ThrottledFuture.of(task)); + + ThrottledFuture result = (ThrottledFuture) sut.throttleMethodCall(joinPointA, throttleA); + + assertNotNull(result); + verifyNoInteractions(transactionExecutor); + + executeScheduledTasks(); + + verify(transactionExecutor).execute(any()); + } + + /** + * When a task is executed, all three maps are cleared from + * entries older than {@link cz.cvut.kbss.termit.util.Constants#THROTTLE_DISCARD_THRESHOLD THROTTLE_DISCARD_THRESHOLD} + * plus {@link cz.cvut.kbss.termit.util.Constants#THROTTLE_THRESHOLD THROTTLE_THRESHOLD} + */ + @Test + void allMapsAreClearedAfterDiscardThreshold() throws Throwable { + sut.throttleMethodCall(joinPointA, throttleA); + sut.throttleMethodCall(joinPointB, throttleB); + sut.throttleMethodCall(joinPointC, throttleC); + skipThreshold(); + executeScheduledTasks(); + sut.throttleMethodCall(joinPointA, throttleA); + sut.throttleMethodCall(joinPointB, throttleB); + sut.throttleMethodCall(joinPointC, throttleC); + executeScheduledTasks(); + skipThreshold(); + sut.throttleMethodCall(joinPointA, throttleA); + sut.throttleMethodCall(joinPointB, throttleB); + sut.throttleMethodCall(joinPointC, throttleC); + addSecond(); + executeScheduledTasks(); + + // skip discard threshold + skipDiscardThreshold(); + sut.throttleMethodCall(joinPointA, throttleA); + + executeScheduledTasks(); + + // only single task left (the last one, which cleared the maps) + assertEquals(1, scheduledFutures.size()); + assertEquals(1, throttledFutures.size()); + assertEquals(1, lastRun.size()); + } + + @Test void aspectDoesNotThrowWhenMethodReturnsUnboxedVoidBySignature() throws Throwable { signatureA.setReturnType(Void.TYPE); @@ -491,7 +813,7 @@ void exceptionPropagatedFromFutureTask() throws Throwable { final String exceptionMessage = "termit exception"; when(joinPointA.proceed()).then(invocation -> new ThrottledFuture<>().update(() -> { throw new TermItException(exceptionMessage); - })); + }, List.of())); signatureA.setReturnType(Future.class); sut.throttleMethodCall(joinPointA, throttleA); @@ -503,7 +825,8 @@ void exceptionPropagatedFromFutureTask() throws Throwable { assertNotNull(scheduled); assertNotNull(future); - assertDoesNotThrow(scheduled::run); + assertDoesNotThrow(scheduled::run); // exception is thrown here, but future stores it + // exception is then re-thrown during future#get() ExecutionException e = assertThrows(ExecutionException.class, future::get); assertEquals(exceptionMessage, e.getCause().getMessage()); } @@ -521,4 +844,97 @@ void resolvedFutureFromMethodIsReturnedWithoutSchedule() throws Throwable { assertFalse(future.isCancelled()); assertEquals(result, future.get()); } + + @Test + void getTasksReturnsCopyOfThrottledFutures() throws Throwable { + sut.throttleMethodCall(joinPointA, throttleA); + sut.throttleMethodCall(joinPointB, throttleB); + sut.throttleMethodCall(joinPointC, throttleC); + + final Collection tasks = sut.getTasks(); + // assert a COPY returned, so they are not the same + assertNotSame(throttledFutures.values(), tasks); + assertIterableEquals(throttledFutures.values(), tasks); + } + + @Test + void aspectThrowsOnMalformedSpel() { + throttleA.setValue("invalid spel expression"); + assertThrows(SpelParseException.class, () -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @Test + void aspectThrowsOnInvalidSpelParamReference() { + throttleA.setValue("{#nonExistingParameter}"); + assertThrows(ThrottleAspectException.class, () -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @Test + void aspectDoesNotThrowsOnStringLiteral() { + throttleA.setValue("'valid spel'"); + assertDoesNotThrow(() -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @Test + void aspectDoesNotThrowsOnEmptyIdentifier() { + throttleA.setValue(""); + assertDoesNotThrow(() -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @Test + void aspectDownNotThrowsOnEmptyGroup() { + throttleA.setGroup(""); + assertDoesNotThrow(() -> sut.throttleMethodCall(joinPointA, throttleA)); + } + + @Test + void aspectConstructsFromAutowiredConstructor() { + assertDoesNotThrow(() -> new ThrottleAspect(taskScheduler, transactionExecutor)); + } + + @Test + void futureWithHigherGroupIsNotCanceledWhenFutureWithLowerGroupIsCanceled() throws Throwable { + // future C has lower group than future A and B + sut.throttleMethodCall(joinPointC, throttleC); + // cancel the scheduled future + scheduledFutures.firstEntry().getValue().cancel(false); + + signatureA.setReturnType(Future.class); + when(joinPointA.proceed()).thenReturn(ThrottledFuture.of(() -> null)); + + Future higherFuture = (Future) sut.throttleMethodCall(joinPointA, throttleA); + + assertNotNull(higherFuture); + assertFalse(higherFuture.isCancelled()); + assertEquals(2, scheduledFutures.size()); + } + + @Test + void newScheduleWithBlankGroupDoesNotCancelsAnyOtherFuture() throws Throwable { + throttleA.setGroup(""); + throttleC.setGroup(""); + + sut.throttleMethodCall(joinPointA, throttleA); // blank group + sut.throttleMethodCall(joinPointB, throttleB); // non blank group + sut.throttleMethodCall(joinPointC, throttleC); // blank group + // no future should be canceled + + assertEquals(3, throttledFutures.size()); + assertEquals(3, scheduledFutures.size()); + + Stream.concat(throttledFutures.values().stream(), scheduledFutures.values().stream()) + .forEach(future -> assertFalse(future.isCancelled())); + } + + @Test + void mapsAreNotClearedWhenFutureIsNotDone() throws Throwable { + sut.throttleMethodCall(joinPointA, throttleA); // blank group + skipDiscardThreshold(); + sut.throttleMethodCall(joinPointB, throttleB); // non blank group + + taskSchedulerTasks.getKey(1).run(); + + assertEquals(2, scheduledFutures.size()); + assertEquals(2, throttledFutures.size()); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java new file mode 100644 index 000000000..1381aad0d --- /dev/null +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java @@ -0,0 +1,118 @@ +package cz.cvut.kbss.termit.util.throttle; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ThrottledFutureTest { + + @Test + void cancelledFactoryMethodReturnsCancelledFuture() { + final ThrottledFuture future = ThrottledFuture.canceled(); + assertTrue(future.isCancelled()); + assertTrue(future.isDone()); // future is done when it is cancelled + assertFalse(future.isRunning()); + } + + @Test + void doneFactoryMethodReturnsDoneFuture() throws Throwable { + final Object result = new Object(); + final ThrottledFuture future = ThrottledFuture.done(result); + assertFalse(future.isCancelled()); + assertTrue(future.isDone()); + assertFalse(future.isRunning()); + final Object futureResult = future.get(1, TimeUnit.SECONDS); + assertNotNull(futureResult); + assertEquals(result, futureResult); + } + + @Test + void getNowReturnsCacheWhenCacheIsAvailable() { + final Object cache = new Object(); + final ThrottledFuture future = ThrottledFuture.of(Object::new).setCachedResult(cache); + final Optional cached = future.getNow(); + assertNotNull(cached); + assertTrue(cached.isPresent()); + assertEquals(cache, cached.get()); + } + + @Test + void getNowReturnsEmptyWhenCacheIsNotAvailable() { + final ThrottledFuture future = ThrottledFuture.of(Object::new); + final Optional cached = future.getNow(); + assertNotNull(cached); + assertTrue(cached.isEmpty()); + } + + @Test + void getNowReturnsEmptyWhenCacheIsNull() { + final ThrottledFuture future = ThrottledFuture.of(Object::new).setCachedResult(null); + final Optional cached = future.getNow(); + assertNotNull(cached); + assertTrue(cached.isEmpty()); + } + + @Test + void thenActionIsExecutedSynchronouslyWhenFutureIsAlreadyDoneAndNotCanceled() { + final Object result = new Object(); + final ThrottledFuture future = ThrottledFuture.of(() -> result); + final AtomicBoolean completed = new AtomicBoolean(false); + final AtomicReference futureResult = new AtomicReference<>(null); + future.run(); + assertTrue(future.isDone()); + assertFalse(future.isCancelled()); + future.then(fResult -> { + completed.set(true); + futureResult.set(fResult); + }); + assertTrue(completed.get()); + assertEquals(result, futureResult.get()); + } + + @Test + void thenActionIsNotExecutedWhenFutureIsAlreadyCancelled() { + final ThrottledFuture future = ThrottledFuture.of(Object::new); + final AtomicBoolean completed = new AtomicBoolean(false); + future.cancel(false); + assertTrue(future.isCancelled()); + future.then(result -> completed.set(true)); + assertFalse(completed.get()); + } + + @Test + void thenActionIsExecutedOnceFutureIsRun() { + final Object result = new Object(); + final AtomicBoolean completed = new AtomicBoolean(false); + final AtomicReference fResult = new AtomicReference<>(null); + final ThrottledFuture future = ThrottledFuture.of(() -> result); + future.then(futureResult -> { + completed.set(true); + fResult.set(futureResult); + }); + assertNull(fResult.get()); + assertFalse(completed.get()); // action was not executed yet + future.run(); + assertTrue(completed.get()); + assertEquals(result, fResult.get()); + } + + @Test + void thenActionIsNotExecutedOnceFutureIsCancelled() { + final Object result = new Object(); + final AtomicBoolean completed = new AtomicBoolean(false); + final ThrottledFuture future = ThrottledFuture.of(() -> result); + future.then(futureResult -> completed.set(true)); + assertFalse(completed.get()); // action was not executed yet + future.cancel(false); + assertFalse(completed.get()); + } +} From f3074c4a8b8b34fae46e426a5e90559c64ed2009 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Fri, 6 Sep 2024 15:16:53 +0200 Subject: [PATCH 100/150] [Performance #287] Adds async tests for ThrottledFuture running --- .../util/longrunning/LongRunningTask.java | 3 +- .../termit/util/throttle/ThrottledFuture.java | 13 ++-- .../util/throttle/ThrottledFutureTest.java | 73 +++++++++++++++++++ 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java index 959b5c13f..f0e17d9af 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java @@ -1,7 +1,6 @@ package cz.cvut.kbss.termit.util.longrunning; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.time.Instant; import java.util.Optional; @@ -32,5 +31,5 @@ public interface LongRunningTask { * or empty if the task execution has not yet started. */ @NotNull - Optional runningSince(); + Optional startedAt(); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index 2852393cf..43f0a63a5 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -14,6 +14,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import java.util.function.Supplier; @@ -33,7 +34,7 @@ public class ThrottledFuture implements CacheableFuture, LongRunningTask { /** * Access only with acquired {@link #lock} */ - private @Nullable Instant completingSince = null; + private AtomicReference<@Nullable Instant> startedAt = new AtomicReference<>(null); private ThrottledFuture(@NotNull final Supplier task) { this.task = task; @@ -61,7 +62,6 @@ public static ThrottledFuture of(@NotNull final Runnable runnable) { public static ThrottledFuture canceled() { ThrottledFuture f = new ThrottledFuture<>(); f.cancel(true); - assert f.isCancelled(); return f; } @@ -71,7 +71,6 @@ public static ThrottledFuture canceled() { public static ThrottledFuture done(T result) { ThrottledFuture f = ThrottledFuture.of(() -> result); f.run(); - assert f.isDone(); return f; } @@ -179,7 +178,7 @@ protected void run() { Thread.yield(); } } while (!locked); - completingSince = Utils.timestamp(); + startedAt.set(Utils.timestamp()); T result = null; if (task != null) { @@ -197,12 +196,12 @@ protected void run() { @Override public boolean isRunning() { - return completingSince != null && !isDone(); + return startedAt.get() != null && !isDone(); } @Override - public @NotNull Optional runningSince() { - return Optional.ofNullable(completingSince); + public @NotNull Optional startedAt() { + return Optional.ofNullable(startedAt.get()); } @Override diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java index 1381aad0d..2e084b2d9 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java @@ -2,11 +2,15 @@ import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.time.Instant; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -115,4 +119,73 @@ void thenActionIsNotExecutedOnceFutureIsCancelled() { future.cancel(false); assertFalse(completed.get()); } + + @Test + void callingRunWillExecuteFutureOnlyOnce() { + AtomicInteger count = new AtomicInteger(0); + final ThrottledFuture future = ThrottledFuture.of(() -> { + count.incrementAndGet(); + }); + + future.run(); + final Optional runningSince = future.startedAt(); + assertTrue(runningSince.isPresent()); + assertTrue(future.isDone()); + assertFalse(future.isCancelled()); + assertFalse(future.isRunning()); + + future.run(); + assertTrue(future.isDone()); + assertFalse(future.isCancelled()); + assertFalse(future.isRunning()); + + // verify that timestamp did not change + assertTrue(future.startedAt().isPresent()); + assertEquals(runningSince.get(), future.startedAt().get()); + } + + /** + * Verifies locks and that second thread exists fast when calls run on already running future. + */ + @Test + void callingRunWillExecuteFutureOnlyOnceAndWontBlockSecondThreadAsync() throws Throwable { + AtomicBoolean allowExit = new AtomicBoolean(false); + AtomicInteger count = new AtomicInteger(0); + final ThrottledFuture future = ThrottledFuture.of(() -> { + count.incrementAndGet(); + while (!allowExit.get()) { + Thread.yield(); + } + }); + final Thread threadA = new Thread(future::run); + final Thread threadB = new Thread(future::run); + threadA.start(); + + await("count incrementation").atMost(Duration.ofSeconds(30)).until(() -> count.get() > 0); + // now there is a threadA spinning in the future task + // locks in the future should be held + assertTrue(future.isRunning()); + assertFalse(future.isDone()); + assertFalse(future.isCancelled()); + + final Optional runningSince = future.startedAt(); + assertTrue(runningSince.isPresent()); + + threadB.start(); + + // thread B should not be blocked + await("threadB start").atMost(Duration.ofSeconds(30)).until(() -> threadB.getState().equals(Thread.State.TERMINATED)); + assertTrue(future.isRunning()); + + allowExit.set(true); + threadA.join(60 * 1000); + threadB.join(60 * 1000); + + assertFalse(threadA.isAlive()); + assertFalse(threadB.isAlive()); + + assertEquals(1, count.get()); + assertTrue(future.startedAt().isPresent()); + assertEquals(runningSince.get(), future.startedAt().get()); + } } From bdd0c409b91c15d3780afa64bfc9202bd81dee84 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 9 Sep 2024 07:20:22 +0200 Subject: [PATCH 101/150] [Performance #287] test for calling web socket exception handler --- .../persistence/validation/Validator.java | 1 - .../WebSocketExceptionHandlerTest.java | 26 ++++++++++++------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java index ab4c6118e..97c72ea77 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java @@ -164,7 +164,6 @@ protected synchronized List runValidation(@NotNull Collection< LOG.trace("Constructing model from RDF4J repository..."); final Model dataModel = getModelFromRdf4jRepository(vocabularyIris); LOG.trace("Model constructed, running validation..."); - // TODO: would be better to cache the validator, but its not thread safe org.topbraid.shacl.validation.ValidationReport report = new com.github.sgov.server.Validator() .validate(dataModel, validationModel); LOG.debug("Done."); diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java index 1271c0943..41590a90b 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java @@ -2,8 +2,10 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; +import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.messaging.simp.stomp.StompCommand; @@ -13,10 +15,14 @@ import java.util.HashMap; -class WebSocketExceptionHandlerTest extends BaseWebSocketControllerTestRunner { +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; - @SpyBean - WebSocketExceptionHandler sut; +class WebSocketExceptionHandlerTest extends BaseWebSocketControllerTestRunner { @MockBean VocabularySocketController controller; @@ -40,12 +46,12 @@ void sendMessage() { this.serverInboundChannel.send(MessageBuilder.withPayload("").setHeaders(messageHeaders).build()); } -// @Test // TODO -// void handlerIsCalledForPersistenceException() { -// final PersistenceException e = new PersistenceException(new Exception("mocked exception")); -// when(controller.validateVocabulary(any(), any())).thenThrow(e); -// sendMessage(); -// verify(sut).persistenceException(notNull(), eq(e)); -// } + @Test + void handlerIsCalledForPersistenceException() { + final PersistenceException e = new PersistenceException(new Exception("mocked exception")); + doThrow(e).when(controller).validateVocabulary(any(), any(), any()); + sendMessage(); + verify(webSocketExceptionHandler).persistenceException(notNull(), eq(e)); + } } From cd2af1e804a3bed725f5318a6a885940728227a1 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 9 Sep 2024 07:22:21 +0200 Subject: [PATCH 102/150] optimize imports --- .../kbss/termit/event/VocabularyValidationFinishedEvent.java | 1 - .../persistence/validation/VocabularyContentValidator.java | 1 - .../cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java | 1 - .../kbss/termit/util/longrunning/LongRunningTaskRegister.java | 1 - .../kbss/termit/service/business/VocabularyServiceTest.java | 2 -- .../cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java | 1 - .../termit/websocket/BaseWebSocketControllerTestRunner.java | 1 - .../termit/websocket/BaseWebSocketIntegrationTestRunner.java | 1 - .../termit/websocket/IntegrationWebSocketSecurityTest.java | 3 --- .../kbss/termit/websocket/WebSocketExceptionHandlerTest.java | 3 --- 10 files changed, 15 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java index 9795036b7..07f0a29ac 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java @@ -3,7 +3,6 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Unmodifiable; -import org.springframework.context.ApplicationEvent; import java.net.URI; import java.util.Collection; diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java index b0b53577a..3d6290b7c 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java @@ -23,7 +23,6 @@ import java.net.URI; import java.util.Collection; -import java.util.List; /** * Allows validating the content of vocabularies based on preconfigured rules. diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index abac7000e..beec29b34 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -45,7 +45,6 @@ import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.ExceptionHandler; diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java index 0838df503..aba93c00d 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java @@ -3,7 +3,6 @@ import org.jetbrains.annotations.NotNull; import java.util.Collection; -import java.util.concurrent.ConcurrentSkipListSet; /** * An object that will schedule a long-running tasks diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java index 93acdeea0..9a9690fc7 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java @@ -75,8 +75,6 @@ import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyBoolean; import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index 80a38955c..bf4186765 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -12,7 +12,6 @@ import org.springframework.expression.spel.SpelParseException; import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.support.TaskUtils; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.transaction.annotation.Transactional; import java.time.Clock; diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java index 4c72f7431..1a9ae8bd2 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java @@ -6,7 +6,6 @@ import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; import cz.cvut.kbss.termit.websocket.util.CachingChannelInterceptor; -import jakarta.annotation.PostConstruct; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java index 13e91ed7f..f41f42874 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java @@ -46,7 +46,6 @@ import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.messaging.WebSocketStompClient; -import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicReference; import static org.mockito.Mockito.doReturn; diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java index 64ad6ee52..c1d2d81b0 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/IntegrationWebSocketSecurityTest.java @@ -17,7 +17,6 @@ import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandler; -import org.springframework.security.core.AuthenticationException; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketMessage; @@ -38,8 +37,6 @@ import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.notNull; import static org.mockito.Mockito.verify; diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java index 41590a90b..a833a953b 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/WebSocketExceptionHandlerTest.java @@ -3,11 +3,9 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.exception.PersistenceException; -import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.MessageBuilder; @@ -20,7 +18,6 @@ import static org.mockito.ArgumentMatchers.notNull; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; class WebSocketExceptionHandlerTest extends BaseWebSocketControllerTestRunner { From ebf4c36cb0d548139183940ab5d867de0dab7998 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 9 Sep 2024 07:40:42 +0200 Subject: [PATCH 103/150] [Performance #287] remove async mvc --- .../cz/cvut/kbss/termit/config/AppConfig.java | 2 +- .../cvut/kbss/termit/config/WebAppConfig.java | 6 ----- .../kbss/termit/rest/ResourceController.java | 10 +++---- .../cvut/kbss/termit/rest/TermController.java | 27 ++++++------------- .../termit/rest/VocabularyController.java | 15 +++-------- .../cz/cvut/kbss/termit/util/Constants.java | 6 ----- .../termit/util/throttle/ThrottleAspect.java | 2 +- .../config/TestRestSecurityConfig.java | 2 +- .../termit/rest/BaseControllerTestRunner.java | 5 ---- .../termit/rest/ResourceControllerTest.java | 4 +-- .../kbss/termit/rest/TermControllerTest.java | 6 ++--- .../termit/rest/VocabularyControllerTest.java | 4 +-- .../util/throttle/ThrottleAspectBeanTest.java | 6 ++--- .../ThrottleAspectTestContextConfig.java | 2 +- 14 files changed, 29 insertions(+), 68 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java index fd05fc0c2..835dd77e5 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java @@ -50,7 +50,7 @@ public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { * This thread pool is responsible for executing long-running tasks in the application. */ @Bean(destroyMethod = "destroy") - public ThreadPoolTaskScheduler threadPoolTaskScheduler(cz.cvut.kbss.termit.util.Configuration config) { + public ThreadPoolTaskScheduler longRunningTaskScheduler(cz.cvut.kbss.termit.util.Configuration config) { ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler(); threadPoolTaskScheduler.setPoolSize(config.getAsyncThreadCount()); threadPoolTaskScheduler.setThreadNamePrefix("TermItScheduler-"); diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java index 0cd55b06f..b8e17d604 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java @@ -49,7 +49,6 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.method.HandlerTypePredicate; -import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping; @@ -202,9 +201,4 @@ public OpenAPI customOpenAPI() { .info(new Info().title("TermIt REST API").description("TermIt REST API definition.") .version(version)); } - - @Override - public void configureAsyncSupport(AsyncSupportConfigurer configurer) { - configurer.setDefaultTimeout(Constants.REST_ASYNC_TIMEOUT); - } } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java index 72ba43dd7..7915556f3 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java @@ -64,7 +64,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.concurrent.Callable; @Tag(name = "Resources", description = "Resource management API") @RestController @@ -307,7 +306,7 @@ public void removeFileFromDocument(@Parameter(description = ResourceControllerDo }) @PutMapping(value = "/{localName}/text-analysis") @ResponseStatus(HttpStatus.NO_CONTENT) - public Callable runTextAnalysis(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION, + public void runTextAnalysis(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION, example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE) @PathVariable String localName, @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION, @@ -318,11 +317,8 @@ public Callable runTextAnalysis(@Parameter(description = ResourceControlle description = "Identifiers of vocabularies whose terms are used to seed text analysis.") @RequestParam(name = "vocabulary", required = false, defaultValue = "") Set vocabularies) { - return () -> { - final Resource resource = getResource(localName, namespace); - resourceService.runTextAnalysis(resource, vocabularies); - return null; - }; + final Resource resource = getResource(localName, namespace); + resourceService.runTextAnalysis(resource, vocabularies); } @Operation(security = {@SecurityRequirement(name = "bearer-key")}, diff --git a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java index 9282701eb..a32307338 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java @@ -72,7 +72,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.concurrent.Callable; import static cz.cvut.kbss.termit.rest.util.RestUtils.createPageRequest; @@ -359,7 +358,7 @@ private URI getTermUri(String vocabIdFragment, String termIdFragment, Optional update( + public void update( @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) @PathVariable String localName, @Parameter(description = ApiDoc.ID_TERM_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_TERM_LOCAL_NAME_EXAMPLE) @@ -370,11 +369,8 @@ public Callable update( @RequestBody Term term) { final URI termUri = getTermUri(localName, termLocalName, namespace); verifyRequestAndEntityIdentifier(term, termUri); - return () -> { - termService.update(term); - LOG.debug("Term {} updated.", term); - return null; - }; + termService.update(term); + LOG.debug("Term {} updated.", term); } @Operation(security = {@SecurityRequirement(name = "bearer-key")}, @@ -386,7 +382,7 @@ public Callable update( }) @PutMapping(value = "/terms/{localName}", consumes = {MediaType.APPLICATION_JSON_VALUE, JsonLd.MEDIA_TYPE}) @ResponseStatus(HttpStatus.NO_CONTENT) - public Callable update( + public void update( @Parameter(description = ApiDoc.ID_STANDALONE_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_TERM_LOCAL_NAME_EXAMPLE) @PathVariable String localName, @@ -397,11 +393,8 @@ public Callable update( @RequestBody Term term) { final URI termUri = idResolver.resolveIdentifier(namespace, localName); verifyRequestAndEntityIdentifier(term, termUri); - return () -> { - termService.update(term); - LOG.debug("Term {} updated.", term); - return null; - }; + termService.update(term); + LOG.debug("Term {} updated.", term); } @Operation(security = {@SecurityRequirement(name = "bearer-key")}, @@ -612,18 +605,14 @@ public List getDefinitionallyRelatedTermsOf( }) @PutMapping(value = "/vocabularies/{localName}/terms/{termLocalName}/text-analysis") @ResponseStatus(HttpStatus.NO_CONTENT) - public Callable runTextAnalysisOnTerm( + public void runTextAnalysisOnTerm( @Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) @PathVariable String localName, @Parameter(description = ApiDoc.ID_TERM_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_TERM_LOCAL_NAME_EXAMPLE) @PathVariable String termLocalName, @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, example = ApiDoc.ID_NAMESPACE_EXAMPLE) @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace) { - return () -> { - termService.analyzeTermDefinition(getById(localName, termLocalName, namespace), - getVocabularyUri(namespace, localName)); - return null; - }; + termService.analyzeTermDefinition(getById(localName, termLocalName, namespace), getVocabularyUri(namespace, localName)); } @Operation(security = {@SecurityRequirement(name = "bearer-key")}, diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 25ffe093e..6c39a1f1d 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -68,7 +68,6 @@ import java.util.Collection; import java.util.List; import java.util.Optional; -import java.util.concurrent.Callable; /** * Vocabulary management REST API. @@ -319,17 +318,14 @@ public void updateVocabulary(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCR description = "Runs text analysis on the definitions of all terms in the vocabulary with the specified identifier.") @PutMapping(value = "/{localName}/terms/text-analysis") @ResponseStatus(HttpStatus.ACCEPTED) - public Callable runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, + public void runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) @PathVariable String localName, @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, example = ApiDoc.ID_NAMESPACE_EXAMPLE) @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace) { - return () -> { - vocabularyService.runTextAnalysisOnAllTerms(getById(localName, namespace)); - return null; - }; + vocabularyService.runTextAnalysisOnAllTerms(getById(localName, namespace)); } /** @@ -343,11 +339,8 @@ public Callable runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc. @GetMapping(value = "/text-analysis") @ResponseStatus(HttpStatus.ACCEPTED) @PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "')") - public Callable runTextAnalysisOnAllVocabularies() { - return () -> { - vocabularyService.runTextAnalysisOnAllVocabularies(); - return null; - }; + public void runTextAnalysisOnAllVocabularies() { + vocabularyService.runTextAnalysisOnAllVocabularies(); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index b457870a6..1930a53c6 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -64,12 +64,6 @@ public class Constants { */ public static final Duration THROTTLE_DISCARD_THRESHOLD = Duration.ofMinutes(1); - /** - * The amount of millis used as timeout for async REST tasks (REST controllers returning Callable): - * 5 minutes - */ - public static final long REST_ASYNC_TIMEOUT = (long) 1000 * 60 * 5; - /** * Default page size. *

    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index ef61b8f6d..322db436b 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -131,7 +131,7 @@ public class ThrottleAspect implements LongRunningTaskRegister { private final @NotNull AtomicReference<@NotNull Instant> lastClear; @Autowired - public ThrottleAspect(@Qualifier("threadPoolTaskScheduler") TaskScheduler taskScheduler, SynchronousTransactionExecutor transactionExecutor) { + public ThrottleAspect(@Qualifier("longRunningTaskScheduler") TaskScheduler taskScheduler, SynchronousTransactionExecutor transactionExecutor) { this.taskScheduler = taskScheduler; this.transactionExecutor = transactionExecutor; throttledFutures = new HashMap<>(); diff --git a/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java b/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java index 6ac7ddba1..b54a9ed4c 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/config/TestRestSecurityConfig.java @@ -76,7 +76,7 @@ public TermItUserDetailsService userDetailsService() { } @Bean - public ThreadPoolTaskScheduler threadPoolTaskScheduler() { + public ThreadPoolTaskScheduler longRunningTaskScheduler() { return mock(ThreadPoolTaskScheduler.class); } } diff --git a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java index 12ef9adc4..fe15f2049 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java @@ -62,11 +62,6 @@ public void setUp(Object controller) { .build(); } - protected ResultActions performAsync(RequestBuilder requestBuilder) throws Exception { - MvcResult async = mockMvc.perform(requestBuilder).andExpect(request().asyncStarted()).andReturn(); - return mockMvc.perform(asyncDispatch(async)); - } - protected void setupObjectMappers() { this.objectMapper = Environment.getObjectMapper(); this.jsonLdObjectMapper = Environment.getJsonLdObjectMapper(); diff --git a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java index 27b9f8393..bd50b7258 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/ResourceControllerTest.java @@ -198,7 +198,7 @@ void runTextAnalysisInvokesTextAnalysisOnSpecifiedResource() throws Exception { final File file = generateFile(); when(identifierResolverMock.resolveIdentifier(RESOURCE_NAMESPACE, FILE_NAME)).thenReturn(file.getUri()); when(resourceServiceMock.findRequired(file.getUri())).thenReturn(file); - performAsync(put(PATH + "/" + FILE_NAME + "/text-analysis").param(QueryParams.NAMESPACE, RESOURCE_NAMESPACE)) + mockMvc.perform(put(PATH + "/" + FILE_NAME + "/text-analysis").param(QueryParams.NAMESPACE, RESOURCE_NAMESPACE)) .andExpect(status().isNoContent()); verify(resourceServiceMock).runTextAnalysis(file, Collections.emptySet()); } @@ -210,7 +210,7 @@ void runTextAnalysisInvokesTextAnalysisWithSpecifiedVocabulariesAsTermSources() when(resourceServiceMock.findRequired(file.getUri())).thenReturn(file); final Set vocabularies = IntStream.range(0, 3).mapToObj(i -> Generator.generateUri().toString()) .collect(Collectors.toSet()); - performAsync(put(PATH + "/" + FILE_NAME + "/text-analysis").param(QueryParams.NAMESPACE, RESOURCE_NAMESPACE) + mockMvc.perform(put(PATH + "/" + FILE_NAME + "/text-analysis").param(QueryParams.NAMESPACE, RESOURCE_NAMESPACE) .param("vocabulary", vocabularies.toArray(new String[0]))) .andExpect(status().isNoContent()); diff --git a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java index 6aa45dcc8..cedfb8e06 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/TermControllerTest.java @@ -193,7 +193,7 @@ void updateUpdatesTerm() throws Exception { final URI termUri = initTermUriResolution(); final Term term = Generator.generateTerm(); term.setUri(termUri); - performAsync(put(PATH + VOCABULARY_NAME + "/terms/" + TERM_NAME).content(toJson(term)).contentType( + mockMvc.perform(put(PATH + VOCABULARY_NAME + "/terms/" + TERM_NAME).content(toJson(term)).contentType( MediaType.APPLICATION_JSON_VALUE)).andExpect(status().isNoContent()); verify(termServiceMock).update(term); } @@ -632,7 +632,7 @@ void updateStandaloneUpdatesTerm() throws Exception { final Term term = Generator.generateTerm(); when(idResolverMock.resolveIdentifier(NAMESPACE, TERM_NAME)).thenReturn(termUri); term.setUri(termUri); - performAsync( + mockMvc.perform( put("/terms/" + TERM_NAME).param(QueryParams.NAMESPACE, NAMESPACE).content(toJson(term)).contentType( MediaType.APPLICATION_JSON_VALUE)).andExpect(status().isNoContent()); verify(idResolverMock).resolveIdentifier(NAMESPACE, TERM_NAME); @@ -770,7 +770,7 @@ void runTextAnalysisInvokesTextAnalysisOnService() throws Exception { when(idResolverMock.resolveIdentifier(NAMESPACE, TERM_NAME)).thenReturn(termUri); when(termServiceMock.findRequired(termUri)).thenReturn(toAnalyze); - performAsync( + mockMvc.perform( put(PATH + VOCABULARY_NAME + "/terms/" + TERM_NAME + "/text-analysis")) .andExpect(status().isNoContent()); verify(termServiceMock).analyzeTermDefinition(toAnalyze, URI.create(VOCABULARY_URI)); diff --git a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java index dcf02e40b..0d1c7444d 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/VocabularyControllerTest.java @@ -412,13 +412,13 @@ void runTextAnalysisOnAllTermsInvokesTextAnalysisOnAllTermsFromService() throws final Vocabulary vocabulary = generateVocabulary(); vocabulary.setUri(VOCABULARY_URI); when(sut.getById(FRAGMENT, Optional.of(NAMESPACE))).thenReturn(vocabulary); - performAsync(put(PATH + "/" + FRAGMENT + "/terms/text-analysis")).andExpect(status().isAccepted()); + mockMvc.perform(put(PATH + "/" + FRAGMENT + "/terms/text-analysis")).andExpect(status().isAccepted()); verify(serviceMock).runTextAnalysisOnAllTerms(vocabulary); } @Test void runTextAnalysisOnAllVocabulariesInvokesTextAnalysisOnAllVocabulariesFromService() throws Exception { - performAsync(get(PATH + "/text-analysis")).andExpect(status().isAccepted()); + mockMvc.perform(get(PATH + "/text-analysis")).andExpect(status().isAccepted()); verify(serviceMock).runTextAnalysisOnAllVocabularies(); } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java index af6c8a913..57ce4aa7c 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java @@ -31,7 +31,7 @@ class ThrottleAspectBeanTest { @Autowired - ThreadPoolTaskScheduler taskScheduler; + ThreadPoolTaskScheduler longRunningTaskScheduler; @SpyBean ThrottleAspect throttleAspect; @@ -41,8 +41,8 @@ class ThrottleAspectBeanTest { @BeforeEach void beforeEach() { - reset(taskScheduler); - when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).then(invocation -> { + reset(longRunningTaskScheduler); + when(longRunningTaskScheduler.schedule(any(Runnable.class), any(Instant.class))).then(invocation -> { Runnable task = invocation.getArgument(0, Runnable.class); return new ScheduledFutureTask<>(task, null); }); diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java index 17c077a20..695b9b0d5 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java @@ -19,7 +19,7 @@ public class ThrottleAspectTestContextConfig { @Bean - public ThreadPoolTaskScheduler threadPoolTaskScheduler() { + public ThreadPoolTaskScheduler longRunningTaskScheduler() { return Mockito.mock(ThreadPoolTaskScheduler.class, RETURNS_SMART_NULLS); } From 25aced4ae2d2b1c6504e2ec0de3728acc09cdd24 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 9 Sep 2024 10:25:20 +0200 Subject: [PATCH 104/150] [Performance #287] prevent concurrent execution of throttled tasks with the same identifier --- .../validation/ResultCachingValidator.java | 12 +- .../service/business/VocabularyService.java | 9 +- .../kbss/termit/util/throttle/Throttle.java | 2 - .../termit/util/throttle/ThrottleAspect.java | 12 +- .../termit/util/throttle/ThrottledFuture.java | 15 +- .../websocket/VocabularySocketController.java | 18 +- .../termit/rest/BaseControllerTestRunner.java | 4 - .../util/throttle/ThrottleAspectTest.java | 169 +++++++----------- 8 files changed, 102 insertions(+), 139 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 3c958ff12..8695d2082 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -19,6 +19,8 @@ import cz.cvut.kbss.termit.event.EvictCacheEvent; import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; +import cz.cvut.kbss.termit.event.VocabularyCreatedEvent; +import cz.cvut.kbss.termit.event.VocabularyEvent; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.util.throttle.Throttle; @@ -54,7 +56,7 @@ public class ResultCachingValidator implements VocabularyContentValidator { /** * Map of origin vocabulary IRI to vocabulary iri closure of imported vocabularies. - * When the value is null, then the cache entry is considered dirty. + * When the record is missing, the cache is considered as dirty. */ private final Map> vocabularyClosure = new ConcurrentHashMap<>(); @@ -80,6 +82,7 @@ private Optional> getCached(@NotNull URI originVoca final Set iris = Set.copyOf(vocabularyIris); if (iris.isEmpty()) { + LOG.warn("Validation of empty IRI list was requested for {}", originVocabularyIri); return ThrottledFuture.done(List.of()); } @@ -122,8 +125,8 @@ Validator getValidator() { return null; // Will be replaced by Spring } - @EventListener - public void evictVocabularyCache(VocabularyContentModifiedEvent event) { + @EventListener({VocabularyContentModifiedEvent.class, VocabularyCreatedEvent.class}) + public void evictVocabularyCache(VocabularyEvent event) { LOG.debug("Vocabulary content modified, marking cache as dirty for {}.", event.getVocabularyIri()); // marked as dirty for specified vocabulary vocabularyClosure.remove(event.getVocabularyIri()); @@ -135,6 +138,9 @@ public void evictVocabularyCache(VocabularyContentModifiedEvent event) { vocabularyClosure.remove(originVocabularyIri); } }); + if (event instanceof VocabularyCreatedEvent) { + validationCache.remove(event.getVocabularyIri()); + } } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 2eebeff0c..92ee4137f 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -66,14 +66,7 @@ import java.io.File; import java.net.URI; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; +import java.util.*; import static cz.cvut.kbss.termit.util.Constants.VOCABULARY_REMOVAL_IGNORED_RELATIONS; diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java index f0fd2422c..6a2b2890e 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java @@ -20,8 +20,6 @@ *

    * *

    - * Every annotated method should be tested for throttling to ensure it has the desired effect. - *

    * Method can't use any parameters that are part of persistent context as the method will be executed on separated thread, * objects need to be re-requested.
    * Call to this method cannot be part of an existing transaction. diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index 322db436b..7284d375e 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -256,7 +256,13 @@ private static StandardEvaluationContext makeDefaultContext() { if (oldScheduledFuture == null || oldThrottledFuture != future || oldScheduledFuture.isDone()) { boolean oldFutureIsDone = oldScheduledFuture == null || oldScheduledFuture.isDone(); - schedule(identifier, task, throttleExpired && oldFutureIsDone); + if (oldThrottledFuture != future) { + oldThrottledFuture.then(ignored -> + schedule(identifier, task, throttleExpired && oldFutureIsDone) + ); + } else { + schedule(identifier, task, throttleExpired && oldFutureIsDone); + } } return result; @@ -353,7 +359,7 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id final Long threadId = Thread.currentThread().getId(); throttledThreads.add(threadId); - LOG.trace("Running throttled task [{} left] '{}'", scheduledFutures.size() - 1, identifier); + LOG.trace("Running throttled task [{} left] [{} running] '{}'", scheduledFutures.size() - 1, throttledThreads.size(), identifier); // restore the security context SecurityContextHolder.setContext(securityContext.get()); @@ -371,7 +377,7 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id } finally { // clear the security context SecurityContextHolder.clearContext(); - LOG.trace("Throttled task run finished '{}'", identifier); + LOG.trace("Finished throttled task [{} left] [{} running] '{}'", scheduledFutures.size() - 1, throttledThreads.size(), identifier); clearOldFutures(); diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index 43f0a63a5..e9992c460 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -22,6 +22,7 @@ public class ThrottledFuture implements CacheableFuture, LongRunningTask { private final ReentrantLock lock = new ReentrantLock(); + private final ReentrantLock callbackLock = new ReentrantLock(); private @Nullable T cachedResult = null; @@ -124,7 +125,8 @@ public T get(long timeout, @NotNull TimeUnit unit) protected ThrottledFuture update(Supplier task, List> onCompletion) { boolean locked = false; try { - locked = lock.tryLock(); + this.callbackLock.lock(); + locked = lock.tryLock(); ThrottledFuture updatedFuture = this; if (!locked || isRunning() || isDone()) { updatedFuture = ThrottledFuture.of(task); @@ -136,6 +138,7 @@ protected ThrottledFuture update(Supplier task, List> onComple if (locked) { lock.unlock(); } + this.callbackLock.unlock(); } } @@ -151,6 +154,7 @@ protected ThrottledFuture update(Supplier task, List> onComple protected ThrottledFuture transfer(ThrottledFuture target) { boolean locked = false; try { + this.callbackLock.lock(); locked = lock.tryLock(); if (!locked || isRunning() || isDone()) { return target; @@ -164,6 +168,7 @@ protected ThrottledFuture transfer(ThrottledFuture target) { if (locked) { lock.unlock(); } + this.callbackLock.unlock(); } } @@ -184,7 +189,9 @@ protected void run() { if (task != null) { result = task.get(); final T finalResult = result; + callbackLock.lock(); onCompletion.forEach(c -> c.accept(finalResult)); + callbackLock.unlock(); } future.complete(result); } finally { @@ -207,7 +214,7 @@ public boolean isRunning() { @Override public void then(Consumer action) { try { - lock.lock(); + callbackLock.lock(); if (future.isDone() && !future.isCancelled()) { try { action.accept(future.get()); @@ -221,9 +228,7 @@ public void then(Consumer action) { onCompletion.add(action); } } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } + callbackLock.unlock(); } } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index a02db3875..e6917cd3d 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -54,14 +54,18 @@ public void validateVocabulary(@DestinationVariable String localName, final CacheableFuture> future = vocabularyService.validateContents(vocabulary.getUri()); future.getNow().ifPresentOrElse(validationResults -> + // if there is a result present (returned from cache), send it + sendToSession( + DESTINATION_VOCABULARIES_VALIDATION, + validationResults, + Map.of("vocabulary", identifier, + // results are cached if we received a future result, but the future is not done yet + "cached", !future.isDone()), + messageHeaders + ), () -> + // otherwise reply will be sent once the future is resolved + future.then(results -> sendToSession( - DESTINATION_VOCABULARIES_VALIDATION, - validationResults, - Map.of("vocabulary", identifier, - "cached", !future.isDone()), - messageHeaders - ), () -> - future.then(results -> sendToSession( DESTINATION_VOCABULARIES_VALIDATION, results, Map.of("vocabulary", identifier, diff --git a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java index fe15f2049..4075ec3fd 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/BaseControllerTestRunner.java @@ -24,8 +24,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; -import org.springframework.test.web.servlet.RequestBuilder; -import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.accept.ContentNegotiationManager; @@ -35,8 +33,6 @@ import static cz.cvut.kbss.termit.environment.Environment.createStringEncodingMessageConverter; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; /** * Common configuration for REST controller tests. diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index bf4186765..a42a34a9b 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -19,17 +19,8 @@ import java.time.Instant; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.NavigableMap; -import java.util.Optional; -import java.util.TreeMap; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.FutureTask; +import java.util.*; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Supplier; @@ -38,28 +29,12 @@ import static cz.cvut.kbss.termit.util.Constants.THROTTLE_DISCARD_THRESHOLD; import static cz.cvut.kbss.termit.util.Constants.THROTTLE_THRESHOLD; import static org.awaitility.Awaitility.await; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertIterableEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -//@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +import static org.mockito.Mockito.*; + class ThrottleAspectTest { + private static final long THREAD_JOIN_TIMEOUT_MILLIS = 60 * 1000; /** * Throttled futures from {@link #sut} @@ -241,6 +216,14 @@ void executeScheduledTasks() { taskSchedulerTasks.clear(); } + void joinThread(Thread thread) throws InterruptedException { + thread.join(THREAD_JOIN_TIMEOUT_MILLIS); + if (thread.isAlive()) { + thread.interrupt(); + fail("task thread thread interrupted due to timeout"); + } + } + /** * If a task was executed more than a threshold period before, it should NOT be debounced */ @@ -464,72 +447,6 @@ void callsAreMergedWhenCalledOutsideTheThresholdButNoFutureExecutedYet() throws assertEquals(secondResult, firstFuture.get()); } - /** - * When task is currently being executed and last execution did not happen in last threshold period, - * next call should not be executed immediately. - * That is because there is already same task running and so a new call should be debounced. - */ - @Test - void callToAMethodDuringTaskExecutionOutsideOfThresholdWillResolveToScheduleOfNewFuture() throws Throwable { - AtomicBoolean allowTaskToFinish = new AtomicBoolean(false); - AtomicBoolean taskRunning = new AtomicBoolean(false); - - // method return type is void, whole method body is considered as a task - when(joinPointA.proceed()).then(invocation -> { - // simulate long running task - taskRunning.set(true); - while (!allowTaskToFinish.get()) { - Thread.yield(); - } - return null; - }); - - // first method call - // there was no call before, which means the task should be scheduled for immediate execution - sut.throttleMethodCall(joinPointA, throttleA); - - Thread taskThread = new Thread(taskSchedulerTasks.getKey(0)); - - try { - // start long task execution - taskThread.start(); - - await("task execution start").atMost(Duration.ofSeconds(30)).untilTrue(taskRunning); - - assertEquals(1, throttledFutures.size()); - assertTrue(throttledFutures.getValue(0).isRunning()); - final Map.Entry immediateSchedule = taskSchedulerTasks.entrySet().getValue(0); - - assertNotNull(immediateSchedule); - // verify that the task was scheduled immediately - assertEquals(getInstant(), immediateSchedule.getValue()); - - // move time by a second - addSecond(); - - // this is second method call in the throttled threshold (time moved only by a second) - sut.throttleMethodCall(joinPointA, throttleA); - // task should not be scheduled for immediate execution - // verify a new task was scheduled - assertEquals(2, taskSchedulerTasks.size()); - - final Map.Entry scheduled = taskSchedulerTasks.entrySet().getValue(1); - assertNotEquals(immediateSchedule, scheduled); - - // the second task should be debounced by a throttle threshold - final Instant expectedSchedule = immediateSchedule.getValue().plusSeconds(1) // added second to the clock - .plus(THROTTLE_THRESHOLD); // should be debounced - assertEquals(expectedSchedule, scheduled.getValue()); - } finally { // ensure that the thread will be terminated in the test - allowTaskToFinish.set(true); - taskThread.join(60 * 1000); /* one minute, ensures that the test won't run indefinitely*/ - if (taskThread.isAlive()) { - taskThread.interrupt(); - fail("task thread thread interrupted due to timeout"); - } - } - } - @Test void cancelsAllScheduledFuturesWhenNewTaskWithLowerGroupIsScheduled() throws Throwable { throttleA.setGroup("'the.group.identifier.first'"); @@ -609,11 +526,7 @@ void callToThrottledMethodReturningVoidFromAlreadyThrottledThreadResultsInSynchr Thread runThread = new Thread(taskSchedulerTasks.getKey(0)); runThread.start(); - runThread.join(15 * 1000); - if (runThread.isAlive()) { - runThread.interrupt(); - fail("task thread thread interrupted due to timeout"); - } + joinThread(runThread); assertNotEquals(-1, threadId.get()); assertEquals(runThread.getId(), threadId.get()); @@ -650,11 +563,7 @@ void callToThrottledMethodReturningFutureFromAlreadyThrottledThreadResultsInSync Thread runThread = new Thread(taskSchedulerTasks.getKey(0)); runThread.start(); - runThread.join(15 * 1000); - if (runThread.isAlive()) { - runThread.interrupt(); - fail("task thread thread interrupted due to timeout"); - } + joinThread(runThread); assertNotEquals(-1, threadId.get()); assertEquals(runThread.getId(), threadId.get()); @@ -842,6 +751,8 @@ void resolvedFutureFromMethodIsReturnedWithoutSchedule() throws Throwable { assertTrue(future.isDone()); assertFalse(future.isCancelled()); assertEquals(result, future.get()); + assertTrue(scheduledFutures.isEmpty()); + assertTrue(taskSchedulerTasks.isEmpty()); } @Test @@ -936,4 +847,48 @@ void mapsAreNotClearedWhenFutureIsNotDone() throws Throwable { assertEquals(2, scheduledFutures.size()); assertEquals(2, throttledFutures.size()); } + + /** + * Scenario:
    + *

      + *
    1. Method is called
    2. + *
    3. Task is scheduled
    4. + *
    5. Task execution starts
    6. + *
    7. Method is called again
    8. + *
    9. New task should be scheduled, but the old one is still executing
    10. + *
    + * This test verify that the second task won't start execution until the old one finishes. + */ + @Test + void noTwoTasksWithTheSameIdentifierShouldBeExecutedConcurrently() throws Throwable { + final AtomicBoolean taskRunning = new AtomicBoolean(false); + final AtomicBoolean allowFinish = new AtomicBoolean(false); + when(joinPointA.proceed()).then(invocation -> { + taskRunning.set(true); + while(!allowFinish.get()) { + Thread.yield(); + } + return null; + }); + + sut.throttleMethodCall(joinPointA, throttleA); + + final Thread firstTask = new Thread(taskSchedulerTasks.getKey(0)); + firstTask.start(); + + await("task execution start").atMost(Duration.ofSeconds(30)).untilTrue(taskRunning); + + assertEquals(1, taskSchedulerTasks.size()); + final ThrottledFuture oldFuture = throttledFutures.getValue(0); + + sut.throttleMethodCall(joinPointA, throttleA); + + assertEquals(1, taskSchedulerTasks.size()); + assertNotEquals(oldFuture, throttledFutures.getValue(0)); + + allowFinish.set(true); + joinThread(firstTask); + + assertEquals(2, taskSchedulerTasks.size()); // new task scheduled after the old one finished + } } From 7a0509c6be974bdfb529841a3f73ef8f53c2360a Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 9 Sep 2024 12:10:07 +0200 Subject: [PATCH 105/150] [Performance #287] adds tests for throttled future --- .../termit/util/throttle/ChainableFuture.java | 2 +- .../termit/util/throttle/ThrottledFuture.java | 14 +- .../util/throttle/ThrottledFutureTest.java | 243 ++++++++++++++++++ 3 files changed, 250 insertions(+), 9 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java index 2696ad217..0d8b63d6c 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java @@ -12,5 +12,5 @@ public interface ChainableFuture extends Future { * If the future is already completed, action is executed synchronously. * @param action action to be executed */ - void then(Consumer action); + ChainableFuture then(Consumer action); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index e9992c460..d2de332c1 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -32,10 +32,7 @@ public class ThrottledFuture implements CacheableFuture, LongRunningTask { private final List> onCompletion = new ArrayList<>(); - /** - * Access only with acquired {@link #lock} - */ - private AtomicReference<@Nullable Instant> startedAt = new AtomicReference<>(null); + private final AtomicReference<@Nullable Instant> startedAt = new AtomicReference<>(null); private ThrottledFuture(@NotNull final Supplier task) { this.task = task; @@ -122,11 +119,11 @@ public T get(long timeout, @NotNull TimeUnit unit) * @return If the current task is already running, was canceled or already completed, returns a new future for the given task. * Otherwise, replaces the current task and returns self. */ - protected ThrottledFuture update(Supplier task, List> onCompletion) { + protected ThrottledFuture update(Supplier task, @NotNull List> onCompletion) { boolean locked = false; try { - this.callbackLock.lock(); locked = lock.tryLock(); + this.callbackLock.lock(); ThrottledFuture updatedFuture = this; if (!locked || isRunning() || isDone()) { updatedFuture = ThrottledFuture.of(task); @@ -154,8 +151,8 @@ protected ThrottledFuture update(Supplier task, List> onComple protected ThrottledFuture transfer(ThrottledFuture target) { boolean locked = false; try { - this.callbackLock.lock(); locked = lock.tryLock(); + this.callbackLock.lock(); if (!locked || isRunning() || isDone()) { return target; } @@ -212,7 +209,7 @@ public boolean isRunning() { } @Override - public void then(Consumer action) { + public ThrottledFuture then(Consumer action) { try { callbackLock.lock(); if (future.isDone() && !future.isCancelled()) { @@ -230,5 +227,6 @@ public void then(Consumer action) { } finally { callbackLock.unlock(); } + return this; } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java index 2e084b2d9..1bc698527 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java @@ -1,21 +1,42 @@ package cz.cvut.kbss.termit.util.throttle; import org.junit.jupiter.api.Test; +import org.mapdb.Atomic; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import java.util.function.Supplier; import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class ThrottledFutureTest { @@ -188,4 +209,226 @@ void callingRunWillExecuteFutureOnlyOnceAndWontBlockSecondThreadAsync() throws T assertTrue(future.startedAt().isPresent()); assertEquals(runningSince.get(), future.startedAt().get()); } + + @Test + void getNowReturnsCachedResultWhenItsAvailable() { + final String futureResult = "future"; + final String cachedResult = "cached"; + ThrottledFuture future = ThrottledFuture.of(() -> futureResult).setCachedResult(cachedResult); + + Optional result = future.getNow(); + assertTrue(result.isPresent()); + assertEquals(cachedResult, result.get()); + } + + @Test + void getNowReturnsEmptyWhenCacheIsNotSet() { + final String futureResult = "future"; + ThrottledFuture future = ThrottledFuture.of(() -> futureResult); + + Optional result = future.getNow(); + assertTrue(result.isEmpty()); + } + + @Test + void getNowReturnsEmptyWhenNullCacheIsSet() { + final String futureResult = "future"; + ThrottledFuture future = ThrottledFuture.of(() -> futureResult).setCachedResult(null); + + Optional result = future.getNow(); + assertTrue(result.isEmpty()); + } + + @Test + void getNowReturnsFutureResultWhenItsDoneAndNotCancelled() { + final String futureResult = "future"; + final String cachedResult = "cached"; + ThrottledFuture future = ThrottledFuture.of(() -> futureResult).setCachedResult(cachedResult); + future.run(); + + Optional result = future.getNow(); + assertTrue(result.isPresent()); + assertEquals(futureResult, result.get()); + } + + @Test + void getNowReturnsCachedResultWhenFutureIsCancelled() { + final String futureResult = "future"; + final String cachedResult = "cached"; + ThrottledFuture future = ThrottledFuture.of(() -> futureResult).setCachedResult(cachedResult); + future.cancel(false); + + Optional result = future.getNow(); + assertTrue(result.isPresent()); + assertEquals(cachedResult, result.get()); + } + + @Test + void onCompletionCallbacksAreNotExecutedWhenTaskIsNull() { + final AtomicBoolean callbackExecuted = new AtomicBoolean(false); + final ThrottledFuture future = new ThrottledFuture<>(); + future.then(ignored -> callbackExecuted.set(true)); + future.run(); + assertFalse(callbackExecuted.get()); + } + + @Test + void transferUpdatesSecondFutureWithTask() { + final Supplier firstTask = () -> null; + final ThrottledFuture firstFuture = ThrottledFuture.of(firstTask); + final ThrottledFuture secondFuture = mock(ThrottledFuture.class); + + firstFuture.transfer(secondFuture); + + verify(secondFuture).update(eq(firstTask), anyList()); + + // now verifies that the task in the first future is null + firstFuture.transfer(secondFuture); + verify(secondFuture).update(isNull(), anyList()); + } + + @Test + void transferUpdatesSecondFutureWithCallbacks() { + final Consumer firstCallback = (result) -> {}; + final Consumer secondCallback = (result) -> {}; + final ThrottledFuture firstFuture = ThrottledFuture.of(()->"").then(firstCallback); + final ThrottledFuture secondFuture = ThrottledFuture.of(()->"").then(secondCallback); + final ThrottledFuture mocked = mock(ThrottledFuture.class); + final List> captured = new ArrayList<>(2); + + when(mocked.update(any(), any())).then(invocation -> { + captured.addAll(invocation.getArgument(1, List.class)); + return mocked; + }); + + firstFuture.transfer(secondFuture); + secondFuture.transfer(mocked); + + verify(mocked).update(notNull(), notNull()); + assertEquals(2, captured.size()); + assertTrue(captured.contains(firstCallback)); + // verifies that callbacks are added to the current ones and do not replace them + assertTrue(captured.contains(secondCallback)); + } + + @Test + void callbacksAreClearedAfterTransferring() { + final Consumer firstCallback = (result) -> {}; + final Consumer secondCallback = (result) -> {}; + final ThrottledFuture future = ThrottledFuture.of(()->"").then(firstCallback).then(secondCallback); + final ThrottledFuture mocked = mock(ThrottledFuture.class); + + future.transfer(mocked); + + final ArgumentCaptor>> captor = ArgumentCaptor.forClass(List.class); + + verify(mocked).update(notNull(), captor.capture()); + // captor takes the original list from the future + // which is cleared afterward + assertTrue(captor.getValue().isEmpty()); + } + + @Test + void transferReturnsTargetWhenFutureIsRunning() { + final ThrottledFuture future = spy(ThrottledFuture.of(()->"")); + final ThrottledFuture target = ThrottledFuture.of(()->""); + when(future.isRunning()).thenReturn(true); + doCallRealMethod().when(future).transfer(any()); + + final ThrottledFuture result = future.transfer(target); + assertEquals(target, result); + } + + @Test + void transferReturnsTargetWhenFutureIsDone() { + final ThrottledFuture future = ThrottledFuture.done(""); + final ThrottledFuture target = ThrottledFuture.of(()->""); + + final ThrottledFuture result = future.transfer(target); + assertEquals(target, result); + } + + @Test + void transferReturnsTargetWhenLockIsNotLockedForTransfer() throws Throwable { + final ReentrantLock futureLock = new ReentrantLock(); + final ThrottledFuture future = ThrottledFuture.of(()->""); + final ThrottledFuture target = ThrottledFuture.of(()->""); + + final Thread thread = new Thread(futureLock::lock); + thread.start(); + thread.join(); + + ReflectionTestUtils.setField(future, "lock", futureLock); + + final ThrottledFuture result = future.transfer(target); + assertEquals(target, result); + } + + @Test + void updateSetsTask() { + final Supplier task = ()->""; + final ThrottledFuture future = ThrottledFuture.of(() -> ""); + + future.update(task, List.of()); + + assertEquals(task, ReflectionTestUtils.getField(future, "task")); + } + + @Test + void updateAddsCallbacksToTheCurrentOnes() { + final Consumer callback = result -> {}; + final Consumer originalCallback = result -> {}; + final ThrottledFuture future = ThrottledFuture.of(() -> "").then(originalCallback); + + future.update(()->"", List.of(callback)); + + final Collection> callbacks = + (Collection>) ReflectionTestUtils.getField(future, "onCompletion"); + + assertNotNull(callbacks); + assertEquals(2, callbacks.size()); + assertTrue(callbacks.contains(originalCallback)); + assertTrue(callbacks.contains(callback)); + } + + @Test + void updateReturnsNewFutureWhenFutureIsRunning() { + final ThrottledFuture future = spy(ThrottledFuture.of(()->"")); + when(future.isRunning()).thenReturn(true); + doCallRealMethod().when(future).update(any(), any()); + + final ThrottledFuture result = future.update(()->"", List.of()); + assertNotEquals(future, result); + } + + @Test + void updateReturnsSelfWhenFutureIsNotRunningAndNotDone() { + final ThrottledFuture future = ThrottledFuture.of(()->""); + + final ThrottledFuture result = future.update(()->"", List.of()); + assertEquals(future, result); + } + + @Test + void updateReturnsNewFutureWhenFutureIsDone() { + final ThrottledFuture future = ThrottledFuture.done(""); + + final ThrottledFuture result = future.update(()->"", List.of()); + assertNotEquals(future, result); + } + + @Test + void updateReturnsNewFutureWhenLockIsNotLockedForUpdate() throws Throwable { + final ReentrantLock futureLock = new ReentrantLock(); + final ThrottledFuture future = ThrottledFuture.of(()->""); + + final Thread thread = new Thread(futureLock::lock); + thread.start(); + thread.join(); + + ReflectionTestUtils.setField(future, "lock", futureLock); + + final ThrottledFuture result = future.update(()->"", List.of()); + assertNotEquals(future, result); + } } From cdfb71dbb604033aa80e7d30f7071a342213ee6d Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 9 Sep 2024 12:36:00 +0200 Subject: [PATCH 106/150] [Performance #287] remove vocabulary content modified event on vocabulary persist --- .../java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index ebaa0d5b3..50f4c8c92 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -192,7 +192,6 @@ public void persist(Vocabulary entity) { } refreshLastModified(); eventPublisher.publishEvent(new AssetPersistEvent(this, entity)); - eventPublisher.publishEvent(new VocabularyContentModifiedEvent(this, entity.getUri())); } catch (RuntimeException e) { throw new PersistenceException(e); } From 21e88d3ce2406ab457df244297b034a904c7b422 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 9 Sep 2024 16:15:56 +0200 Subject: [PATCH 107/150] [Performance #285] publishing text analysis to websocket --- ...cabularyFileTextAnalysisFinishedEvent.java | 23 ++++++++ ...rmDefinitionTextAnalysisFinishedEvent.java | 22 ++++++++ .../kbss/termit/rest/ResourceController.java | 2 +- .../cvut/kbss/termit/rest/TermController.java | 1 + .../termit/rest/VocabularyController.java | 2 + .../service/document/AnnotationGenerator.java | 1 - .../service/document/TextAnalysisService.java | 11 +++- .../websocket/VocabularySocketController.java | 54 +++++++++++++++---- .../websocket/WebSocketDestinations.java | 25 +++++++++ 9 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/event/VocabularyFileTextAnalysisFinishedEvent.java create mode 100644 src/main/java/cz/cvut/kbss/termit/event/VocabularyTermDefinitionTextAnalysisFinishedEvent.java create mode 100644 src/main/java/cz/cvut/kbss/termit/websocket/WebSocketDestinations.java diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyFileTextAnalysisFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyFileTextAnalysisFinishedEvent.java new file mode 100644 index 000000000..3ebf85a96 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyFileTextAnalysisFinishedEvent.java @@ -0,0 +1,23 @@ +package cz.cvut.kbss.termit.event; + +import cz.cvut.kbss.termit.model.resource.File; +import org.jetbrains.annotations.NotNull; + +import java.net.URI; + +/** + * Indicates that text analysis of a file was finished + */ +public class VocabularyFileTextAnalysisFinishedEvent extends VocabularyEvent { + + private final URI fileUri; + + public VocabularyFileTextAnalysisFinishedEvent(Object source, @NotNull File file) { + super(source, file.getDocument().getVocabulary()); + this.fileUri = file.getUri(); + } + + public URI getFileUri() { + return fileUri; + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyTermDefinitionTextAnalysisFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyTermDefinitionTextAnalysisFinishedEvent.java new file mode 100644 index 000000000..0ebad21a4 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyTermDefinitionTextAnalysisFinishedEvent.java @@ -0,0 +1,22 @@ +package cz.cvut.kbss.termit.event; + +import cz.cvut.kbss.termit.model.AbstractTerm; +import org.jetbrains.annotations.NotNull; + +import java.net.URI; + +/** + * Indicates that a text analysis of a term definition was finished + */ +public class VocabularyTermDefinitionTextAnalysisFinishedEvent extends VocabularyEvent { + private final URI termUri; + + public VocabularyTermDefinitionTextAnalysisFinishedEvent(Object source, @NotNull AbstractTerm term) { + super(source, term.getVocabulary()); + this.termUri = term.getUri(); + } + + public URI getTermUri() { + return termUri; + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java index 7915556f3..11bb65415 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java @@ -298,7 +298,7 @@ public void removeFileFromDocument(@Parameter(description = ResourceControllerDo } @Operation(security = {@SecurityRequirement(name = "bearer-key")}, - description = "Runs text analysis on the content of the resource with the specified identifier.") + description = "Runs text analysis on the content of the resource with the specified identifier. Analysis will be performed asynchronously sometime in the future.") @ApiResponses({ @ApiResponse(responseCode = "204", description = "Text analysis executed."), @ApiResponse(responseCode = "404", description = ResourceControllerDoc.ID_NOT_FOUND_DESCRIPTION), diff --git a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java index a32307338..9fc059aa9 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java @@ -612,6 +612,7 @@ public void runTextAnalysisOnTerm( @PathVariable String termLocalName, @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, example = ApiDoc.ID_NAMESPACE_EXAMPLE) @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace) { + LOG.warn("Called legacy endpoint intended for internal use or testing only! (/vocabularies/{}/terms/{}/text-analysis)", localName, termLocalName); termService.analyzeTermDefinition(getById(localName, termLocalName, namespace), getVocabularyUri(namespace, localName)); } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 6c39a1f1d..3b5d52a56 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -325,6 +325,7 @@ public void runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc.ID_LOCAL_N example = ApiDoc.ID_NAMESPACE_EXAMPLE) @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace) { + LOG.warn("Called legacy endpoint intended for internal use or testing only! (/vocabularies/{}/terms/text-analysis)", localName); vocabularyService.runTextAnalysisOnAllTerms(getById(localName, namespace)); } @@ -340,6 +341,7 @@ public void runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc.ID_LOCAL_N @ResponseStatus(HttpStatus.ACCEPTED) @PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "')") public void runTextAnalysisOnAllVocabularies() { + LOG.warn("Called legacy endpoint intended for internal use or testing only! (/vocabularies/text-analysis)"); vocabularyService.runTextAnalysisOnAllVocabularies(); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index cf4fef6e4..aa2104886 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -148,7 +148,6 @@ private void saveAnnotatedContent(File file, InputStream input) { * @param annotatedTerm Term whose definition was annotated */ @Transactional - @Throttle(value = "{#annotatedTerm.getUri()}") public void generateAnnotations(InputStream content, AbstractTerm annotatedTerm) { // We assume the content (text analysis output) is HTML-compatible final TermOccurrenceResolver occurrenceResolver = resolvers.htmlTermOccurrenceResolver(); diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java index c0b693de4..4884b8493 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java @@ -18,6 +18,8 @@ package cz.cvut.kbss.termit.service.document; import cz.cvut.kbss.termit.dto.TextAnalysisInput; +import cz.cvut.kbss.termit.event.VocabularyFileTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.VocabularyTermDefinitionTextAnalysisFinishedEvent; import cz.cvut.kbss.termit.exception.WebServiceIntegrationException; import cz.cvut.kbss.termit.model.AbstractTerm; import cz.cvut.kbss.termit.model.TextAnalysisRecord; @@ -29,6 +31,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.Resource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -62,14 +65,18 @@ public class TextAnalysisService { private final TextAnalysisRecordDao recordDao; + private final ApplicationEventPublisher eventPublisher; + @Autowired public TextAnalysisService(RestTemplate restClient, Configuration config, DocumentManager documentManager, - AnnotationGenerator annotationGenerator, TextAnalysisRecordDao recordDao) { + AnnotationGenerator annotationGenerator, TextAnalysisRecordDao recordDao, + ApplicationEventPublisher eventPublisher) { this.restClient = restClient; this.config = config; this.documentManager = documentManager; this.annotationGenerator = annotationGenerator; this.recordDao = recordDao; + this.eventPublisher = eventPublisher; } /** @@ -89,6 +96,7 @@ public void analyzeFile(File file, Set vocabularyContexts) { input.setVocabularyContexts(vocabularyContexts); invokeTextAnalysisOnFile(file, input); LOG.debug("Text analysis finished for resource {}.", file.getUri()); + eventPublisher.publishEvent(new VocabularyFileTextAnalysisFinishedEvent(this, file)); } private TextAnalysisInput createAnalysisInput(File file) { @@ -182,6 +190,7 @@ public void analyzeTermDefinition(AbstractTerm term, URI vocabularyContext) { input.setVocabularyRepositoryPassword(config.getRepository().getPassword()); invokeTextAnalysisOnTerm(term, input); + eventPublisher.publishEvent(new VocabularyTermDefinitionTextAnalysisFinishedEvent(this, term)); } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index e6917cd3d..ee62d2fc4 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -1,5 +1,8 @@ package cz.cvut.kbss.termit.websocket; +import cz.cvut.kbss.termit.event.VocabularyEvent; +import cz.cvut.kbss.termit.event.VocabularyFileTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.VocabularyTermDefinitionTextAnalysisFinishedEvent; import cz.cvut.kbss.termit.event.VocabularyValidationFinishedEvent; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.validation.ValidationResult; @@ -21,6 +24,7 @@ import java.net.URI; import java.util.Collection; +import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -29,8 +33,6 @@ @PreAuthorize("hasRole('" + SecurityConstants.ROLE_RESTRICTED_USER + "')") public class VocabularySocketController extends BaseWebSocketController { - private static final String DESTINATION_VOCABULARIES_VALIDATION = "/vocabularies/validation"; - private final VocabularyService vocabularyService; protected VocabularySocketController(IdentifierResolver idResolver, Configuration config, @@ -56,20 +58,20 @@ public void validateVocabulary(@DestinationVariable String localName, future.getNow().ifPresentOrElse(validationResults -> // if there is a result present (returned from cache), send it sendToSession( - DESTINATION_VOCABULARIES_VALIDATION, + WebSocketDestinations.VOCABULARIES_VALIDATION, validationResults, - Map.of("vocabulary", identifier, + getHeaders(identifier, // results are cached if we received a future result, but the future is not done yet - "cached", !future.isDone()), + Map.of("cached", !future.isDone())), messageHeaders ), () -> // otherwise reply will be sent once the future is resolved future.then(results -> sendToSession( - DESTINATION_VOCABULARIES_VALIDATION, + WebSocketDestinations.VOCABULARIES_VALIDATION, results, - Map.of("vocabulary", identifier, - "cached", false), + getHeaders(identifier, + Map.of("cached", false)), messageHeaders )) ); @@ -82,9 +84,41 @@ public void validateVocabulary(@DestinationVariable String localName, @EventListener public void onVocabularyValidationFinished(VocabularyValidationFinishedEvent event) { messagingTemplate.convertAndSend( - DESTINATION_VOCABULARIES_VALIDATION, + WebSocketDestinations.VOCABULARIES_VALIDATION, event.getValidationResults(), - Map.of("vocabulary", event.getVocabularyIri(), "cached", false) + getHeaders(event.getVocabularyIri(), Map.of("cached", false)) ); } + + @EventListener + public void onFileTextAnalysisFinished(VocabularyFileTextAnalysisFinishedEvent event) { + messagingTemplate.convertAndSend( + WebSocketDestinations.VOCABULARIES_TEXT_ANALYSIS_FINISHED_FILE, + event.getFileUri(), + getHeaders(event) + ); + } + + @EventListener + public void onTermDefinitionTextAnalysisFinished(VocabularyTermDefinitionTextAnalysisFinishedEvent event) { + messagingTemplate.convertAndSend( + WebSocketDestinations.VOCABULARIES_TEXT_ANALYSIS_FINISHED_TERM_DEFINITION, + event.getTermUri(), + getHeaders(event) + ); + } + + protected @NotNull Map getHeaders(@NotNull VocabularyEvent event) { + return getHeaders(event.getVocabularyIri()); + } + + protected @NotNull Map getHeaders(@NotNull URI vocabularyUri) { + return getHeaders(vocabularyUri, Map.of()); + } + + protected @NotNull Map getHeaders(@NotNull URI vocabularyUri, Map headers) { + final Map headersMap = new HashMap<>(headers); + headersMap.put("vocabulary", vocabularyUri); + return headersMap; + } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/WebSocketDestinations.java b/src/main/java/cz/cvut/kbss/termit/websocket/WebSocketDestinations.java new file mode 100644 index 000000000..ee8d088e3 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/websocket/WebSocketDestinations.java @@ -0,0 +1,25 @@ +package cz.cvut.kbss.termit.websocket; + +public final class WebSocketDestinations { + + /** + * Used for publishing results of validation from server to clients + */ + public static final String VOCABULARIES_VALIDATION = "/vocabularies/validation"; + + private static final String VOCABULARIES_TEXT_ANALYSIS_FINISHED = "/vocabularies/text_analysis/finished"; + + /** + * Used for notifying clients about a text analysis end + */ + public static final String VOCABULARIES_TEXT_ANALYSIS_FINISHED_FILE = VOCABULARIES_TEXT_ANALYSIS_FINISHED + "/file"; + + /** + * Used for notifying clients about a text analysis end + */ + public static final String VOCABULARIES_TEXT_ANALYSIS_FINISHED_TERM_DEFINITION = VOCABULARIES_TEXT_ANALYSIS_FINISHED + "/term-definition"; + + private WebSocketDestinations() { + throw new AssertionError(); + } +} From bab3bef981a8d09d971846b503a99fea8a0bfe97 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 10 Sep 2024 07:42:59 +0200 Subject: [PATCH 108/150] [Performance #285] fix text analysis tests --- .../document/TextAnalysisServiceTest.java | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java index 560d6ddd0..c49e33ff3 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java @@ -17,20 +17,25 @@ */ package cz.cvut.kbss.termit.service.document; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.termit.dto.TextAnalysisInput; import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.environment.PropertyMockingApplicationContextInitializer; +import cz.cvut.kbss.termit.event.VocabularyFileTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.VocabularyTermDefinitionTextAnalysisFinishedEvent; import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.exception.WebServiceIntegrationException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.TextAnalysisRecord; import cz.cvut.kbss.termit.model.Vocabulary; +import cz.cvut.kbss.termit.model.resource.Document; import cz.cvut.kbss.termit.model.resource.File; import cz.cvut.kbss.termit.persistence.dao.TextAnalysisRecordDao; import cz.cvut.kbss.termit.service.BaseServiceTestRunner; +import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; import org.junit.jupiter.api.BeforeEach; @@ -44,6 +49,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -67,6 +73,7 @@ import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; @@ -100,6 +107,9 @@ class TextAnalysisServiceTest extends BaseServiceTestRunner { @Autowired private Configuration config; + @Mock + private ApplicationEventPublisher eventPublisher; + @Autowired private DocumentManager documentManager; @@ -128,12 +138,14 @@ void setUp() throws Exception { this.file = new File(); file.setUri(Generator.generateUri()); file.setLabel(FILE_NAME); + file.setDocument(Generator.generateDocumentWithId()); + file.getDocument().setVocabulary(vocabulary.getUri()); generateFile(); this.documentManagerSpy = spy(documentManager); doCallRealMethod().when(documentManagerSpy).loadFileContent(any()); doNothing().when(documentManagerSpy).createBackup(any()); this.sut = new TextAnalysisService(restTemplate, config, documentManagerSpy, annotationGeneratorMock, - textAnalysisRecordDao); + textAnalysisRecordDao, eventPublisher); } @Test @@ -149,8 +161,7 @@ private void generateFile() throws IOException { final java.io.File dir = Files.createTempDirectory("termit").toFile(); dir.deleteOnExit(); config.getFile().setStorage(dir.getAbsolutePath()); - final java.io.File docDir = new java.io.File(dir.getAbsolutePath() + java.io.File.separator + - file.getDirectoryName()); + final java.io.File docDir = new java.io.File(dir.getAbsolutePath() + java.io.File.separator + file.getDirectoryName()); Files.createDirectory(docDir.toPath()); docDir.deleteOnExit(); final java.io.File content = new java.io.File( @@ -407,4 +418,38 @@ void analyzeTermDefinitionInvokesTextAnalysisServiceWithVocabularyRepositoryUser sut.analyzeTermDefinition(term, vocabulary.getUri()); mockServer.verify(); } + + @Test + void analyzeFilePublishesAnalysisFinishedEvent() { + mockServer.expect(requestTo(config.getTextAnalysis().getUrl())) + .andExpect(method(HttpMethod.POST)).andExpect(content().string(containsString(CONTENT))) + .andRespond(withSuccess(CONTENT, MediaType.APPLICATION_XML)); + sut.analyzeFile(file, Collections.singleton(vocabulary.getUri())); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(VocabularyFileTextAnalysisFinishedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertNotNull(eventCaptor.getValue()); + assertEquals(file.getUri(), eventCaptor.getValue().getFileUri()); + assertEquals(vocabulary.getUri(), eventCaptor.getValue().getVocabularyIri()); + } + + @Test + void analyzeTermDefinitionPublishesAnalysisFinishedEvent() throws JsonProcessingException { + final Term term = Generator.generateTermWithId(); + term.setVocabulary(vocabulary.getUri()); + final TextAnalysisInput input = textAnalysisInput(); + input.setContent(term.getDefinition().get(Environment.LANGUAGE)); + mockServer.expect(requestTo(config.getTextAnalysis().getUrl())) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().string(objectMapper.writeValueAsString(input))) + .andRespond(withSuccess(CONTENT, MediaType.APPLICATION_XML)); + + sut.analyzeTermDefinition(term, vocabulary.getUri()); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(VocabularyTermDefinitionTextAnalysisFinishedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertNotNull(eventCaptor.getValue()); + assertEquals(term.getUri(), eventCaptor.getValue().getTermUri()); + assertEquals(vocabulary.getUri(), eventCaptor.getValue().getVocabularyIri()); + } } From 12a69c9cbac690deeac38fe7161b3c330fe12e14 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 10 Sep 2024 08:57:55 +0200 Subject: [PATCH 109/150] [Performance #285] remove trace time logging and improve throttled futures stats logging --- .../persistence/dao/TermOccurrenceDao.java | 5 +--- .../termit/util/throttle/ThrottleAspect.java | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java index c88f4025c..0fbe3efb3 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java @@ -256,13 +256,10 @@ public void removeAll(Asset target) { final URI sourceContext = TermOccurrence.resolveContext(target.getUri()); LOG.debug("Removing all occurrences from {}", sourceContext); - final StopWatch stopWatch = new StopWatch(); - stopWatch.start(); em.createNativeQuery("DROP GRAPH ?context") .setParameter("context", sourceContext) .executeUpdate(); - stopWatch.stop(); - LOG.debug("Removed all occurrences from {} in {} ms", sourceContext, stopWatch.getTotalTimeMillis()); + LOG.debug("Removed all occurrences from {}", sourceContext); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index 7284d375e..c76bba9ba 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -244,6 +244,7 @@ private static StandardEvaluationContext makeDefaultContext() { Runnable task = pair.getFirst(); ThrottledFuture future = pair.getSecond(); // update the throttled future in the map, it might be just the same future, but it might be a new one + // update the throttled future in the map, it might be just the same future, but it might be a new one synchronized (throttledFutures) { throttledFutures.put(identifier, future); } @@ -348,6 +349,22 @@ private Pair> getFutureTask(@NotNull Proceedin return new Pair<>(toSchedule, throttledFuture); } + /** + * @return the number of throttled futures that are neither done nor running. + */ + private long countRemaining() { + synchronized (throttledFutures) { + return throttledFutures.values().stream().filter(f -> !f.isDone() && !f.isRunning()).count(); + } + } + + /** + * @return count of throttled threads + */ + private long countRunning() { + return throttledThreads.size(); + } + private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Identifier identifier, boolean withTransaction) { final Supplier securityContext = SecurityContextHolder.getDeferredContext(); @@ -359,7 +376,7 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id final Long threadId = Thread.currentThread().getId(); throttledThreads.add(threadId); - LOG.trace("Running throttled task [{} left] [{} running] '{}'", scheduledFutures.size() - 1, throttledThreads.size(), identifier); + LOG.trace("Running throttled task [{} left] [{} running] '{}'", countRemaining() - 1, countRunning(), identifier); // restore the security context SecurityContextHolder.setContext(securityContext.get()); @@ -377,7 +394,7 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id } finally { // clear the security context SecurityContextHolder.clearContext(); - LOG.trace("Finished throttled task [{} left] [{} running] '{}'", scheduledFutures.size() - 1, throttledThreads.size(), identifier); + LOG.trace("Finished throttled task [{} left] [{} running] '{}'", countRemaining(), countRunning() - 1, identifier); clearOldFutures(); @@ -543,7 +560,7 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati @Override public @NotNull Collection getTasks() { synchronized (throttledFutures) { - return List.copyOf(throttledFutures.values()); + return throttledFutures.values().stream().filter(f -> !f.isDone()).map(f -> (LongRunningTask) f).toList(); } } From 9e3212295b03effb4e40fafbbde910b070d68209 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 10 Sep 2024 10:50:38 +0200 Subject: [PATCH 110/150] [Performance #285] rename term service tests for better description --- .../cvut/kbss/termit/service/business/TermServiceTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java index 139307564..55372e8ae 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java @@ -317,7 +317,7 @@ void runTextAnalysisInvokesTextAnalysisOnSpecifiedTerm() { } @Test - void persistChildInvokesTextAnalysisOnPersistedChildTerm() { + void persistChildInvokesTextAnalysisOnAllTermsInVocabulary() { when(vocabularyService.findRequired(vocabulary.getUri())).thenReturn(vocabulary); final Term parent = generateTermWithId(); parent.setVocabulary(vocabulary.getUri()); @@ -327,7 +327,7 @@ void persistChildInvokesTextAnalysisOnPersistedChildTerm() { } @Test - void persistRootInvokesTextAnalysisOnPersistedRootTerm() { + void persistRootInvokesTextAnalysisOnAllTermsInVocabulary() { final Term toPersist = generateTermWithId(); sut.persistRoot(toPersist, vocabulary); verify(vocabularyService).runTextAnalysisOnAllTerms(vocabulary); @@ -349,7 +349,7 @@ void updateInvokesTextAnalysisOnUpdatedTerm() { } @Test - void updateOfTermLabelInvokesTextAnalysisOnAllTerms() { + void updateOfTermLabelInvokesTextAnalysisOnAllTermsInVocabulary() { final Term original = generateTermWithId(vocabulary.getUri()); final Term toUpdate = new Term(original.getUri()); toUpdate.setLabel(MultilingualString.create("new Label", Environment.LANGUAGE)); From 75e32dd0cbddec30db1e87d2d7868555b9057925 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 10 Sep 2024 11:09:02 +0200 Subject: [PATCH 111/150] [Performance #285] remove unused class MockedFuture --- .../termit/environment/util/MockedFuture.java | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 src/test/java/cz/cvut/kbss/termit/environment/util/MockedFuture.java diff --git a/src/test/java/cz/cvut/kbss/termit/environment/util/MockedFuture.java b/src/test/java/cz/cvut/kbss/termit/environment/util/MockedFuture.java deleted file mode 100644 index 61987c37b..000000000 --- a/src/test/java/cz/cvut/kbss/termit/environment/util/MockedFuture.java +++ /dev/null @@ -1,57 +0,0 @@ -package cz.cvut.kbss.termit.environment.util; - -import org.jetbrains.annotations.NotNull; - -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -/** - * Utility class implementing future interface - */ -public class MockedFuture implements Future { - - private final T result; - - private boolean cancelled; - - private boolean done; - - public MockedFuture(T result) { - this.result = result; - this.done = true; - this.cancelled = false; - } - - - @Override - public boolean cancel(boolean mayInterruptIfRunning) { - if (done) { - return false; - } - this.cancelled = true; - return true; - } - - @Override - public boolean isCancelled() { - return cancelled; - } - - @Override - public boolean isDone() { - return done; - } - - @Override - public T get() throws InterruptedException, ExecutionException { - return result; - } - - @Override - public T get(long timeout, @NotNull TimeUnit unit) - throws InterruptedException, ExecutionException, TimeoutException { - return result; - } -} From 41644ade46b5937570c7d40e98a1528192715579 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 10 Sep 2024 11:15:42 +0200 Subject: [PATCH 112/150] optimize imports --- .../persistence/dao/TermOccurrenceDao.java | 1 - .../termit/persistence/dao/VocabularyDao.java | 1 - .../service/business/VocabularyService.java | 9 ++++- .../document/TermOccurrenceResolver.java | 2 +- .../document/TextAnalysisServiceTest.java | 2 -- .../util/throttle/ThrottleAspectTest.java | 33 ++++++++++++++++--- .../util/throttle/ThrottledFutureTest.java | 2 -- 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java index 0fbe3efb3..e623ff3b3 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java @@ -35,7 +35,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Repository; -import org.springframework.util.StopWatch; import java.net.URI; import java.util.List; diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index 50f4c8c92..bf61cf07e 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -30,7 +30,6 @@ import cz.cvut.kbss.termit.event.AssetPersistEvent; import cz.cvut.kbss.termit.event.AssetUpdateEvent; import cz.cvut.kbss.termit.event.RefreshLastModifiedEvent; -import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent; import cz.cvut.kbss.termit.event.VocabularyWillBeRemovedEvent; import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.model.Glossary; diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 92ee4137f..2eebeff0c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -66,7 +66,14 @@ import java.io.File; import java.net.URI; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import static cz.cvut.kbss.termit.util.Constants.VOCABULARY_REMOVAL_IGNORED_RELATIONS; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java index 0139a5d30..616c0707d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java @@ -117,7 +117,7 @@ protected TermOccurrence createOccurrence(URI termUri, Asset source) { } @FunctionalInterface - public static interface OccurrenceConsumer { + public interface OccurrenceConsumer { void accept(TermOccurrence termOccurrence) throws InterruptedException; } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java index c49e33ff3..5b4df0ad8 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java @@ -31,11 +31,9 @@ import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.model.TextAnalysisRecord; import cz.cvut.kbss.termit.model.Vocabulary; -import cz.cvut.kbss.termit.model.resource.Document; import cz.cvut.kbss.termit.model.resource.File; import cz.cvut.kbss.termit.persistence.dao.TextAnalysisRecordDao; import cz.cvut.kbss.termit.service.BaseServiceTestRunner; -import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index a42a34a9b..81b9a9438 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -19,8 +19,17 @@ import java.time.Instant; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.*; -import java.util.concurrent.*; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Supplier; @@ -29,9 +38,25 @@ import static cz.cvut.kbss.termit.util.Constants.THROTTLE_DISCARD_THRESHOLD; import static cz.cvut.kbss.termit.util.Constants.THROTTLE_THRESHOLD; import static org.awaitility.Awaitility.await; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; class ThrottleAspectTest { private static final long THREAD_JOIN_TIMEOUT_MILLIS = 60 * 1000; diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java index 1bc698527..33e76fb00 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java @@ -1,7 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; import org.junit.jupiter.api.Test; -import org.mapdb.Atomic; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; @@ -27,7 +26,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; From 1e39d9fdbbc2a65095b10948c0991c028eb7da60 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 10 Sep 2024 11:32:28 +0200 Subject: [PATCH 113/150] code cleanup --- .../kbss/termit/persistence/dao/TermOccurrenceDaoTest.java | 5 ----- .../kbss/termit/service/business/VocabularyServiceTest.java | 6 ++---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java index ad8ac95e8..bb1887a51 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDaoTest.java @@ -72,9 +72,6 @@ class TermOccurrenceDaoTest extends BaseDaoTestRunner { @Autowired private EntityManager em; -// @Autowired -// private ScheduledContextRemover contextRemover; - @Autowired private TermOccurrenceDao sut; @@ -269,7 +266,6 @@ void removeAllRemovesSuggestedAndConfirmedOccurrences() { }))); transactional(() -> { sut.removeAll(file); -// contextRemover.runContextRemoval(); }); assertTrue(sut.findAllTargeting(file).isEmpty()); assertFalse(em.createNativeQuery("ASK { ?x a ?termOccurrence . }", Boolean.class).setParameter("termOccurrence", @@ -291,7 +287,6 @@ void removeAllRemovesAlsoOccurrenceTargets() { }))); transactional(() -> { sut.removeAll(file); -// contextRemover.runContextRemoval(); }); assertFalse(em.createNativeQuery("ASK { ?x a ?target . }", Boolean.class).setParameter("target", diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java index 9a9690fc7..68d6b4a5e 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java @@ -127,10 +127,8 @@ void runTextAnalysisOnAllTermsInvokesTextAnalysisOnAllTermsInVocabulary() { when(repositoryService.getTransitivelyImportedVocabularies(vocabulary)).thenReturn(Collections.emptyList()); when(repositoryService.findRequired(vocabulary.getUri())).thenReturn(vocabulary); sut.runTextAnalysisOnAllTerms(vocabulary); - Map expected = Map.of(termOne, vocabulary.getUri(), termTwo, vocabulary.getUri()); - for(final Map.Entry entry : expected.entrySet()) { - verify(termService).analyzeTermDefinition(entry.getKey(), entry.getValue()); - } + verify(termService).analyzeTermDefinition(termOne, vocabulary.getUri()); + verify(termService).analyzeTermDefinition(termTwo, vocabulary.getUri()); } @Test From 1d40b8c88f40a4dfb27f4c83af34248e00113d6c Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 10 Sep 2024 15:31:07 +0200 Subject: [PATCH 114/150] [Performance #285] Implement API for retrieval of long-running tasks status --- .../cvut/kbss/termit/config/WebAppConfig.java | 1 + .../kbss/termit/dto/LongRunningTaskDto.java | 39 ++++++++++++++ .../event/LongRunningTaskChangedEvent.java | 39 ++++++++++++++ .../validation/ResultCachingValidator.java | 2 +- .../persistence/validation/Validator.java | 2 +- .../termit/service/business/TermService.java | 4 +- .../service/business/VocabularyService.java | 5 +- .../service/document/AnnotationGenerator.java | 2 +- .../service/document/TextAnalysisService.java | 2 +- .../util/longrunning/LongRunningTask.java | 6 ++- .../longrunning/LongRunningTaskRegister.java | 18 ------- .../longrunning/LongRunningTaskScheduler.java | 23 ++++++++ .../longrunning/LongRunningTaskStatus.java | 52 +++++++++++++++++++ .../longrunning/LongRunningTasksRegistry.java | 43 +++++++++++++++ .../kbss/termit/util/throttle/Throttle.java | 3 ++ .../termit/util/throttle/ThrottleAspect.java | 43 ++++++++------- .../termit/util/throttle/ThrottledFuture.java | 23 +++++++- .../LongRunningTasksWebSocketController.java | 27 ++++++++++ .../websocket/WebSocketDestinations.java | 5 ++ .../business/VocabularyServiceTest.java | 2 - .../termit/util/throttle/MockedThrottle.java | 6 +++ .../util/throttle/TestFutureRunner.java | 2 +- .../util/throttle/ThrottleAspectTest.java | 24 +++------ .../util/throttle/ThrottledFutureTest.java | 31 ++++++++--- 24 files changed, 325 insertions(+), 79 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/termit/dto/LongRunningTaskDto.java create mode 100644 src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java delete mode 100644 src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java create mode 100644 src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java create mode 100644 src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java index b8e17d604..eaa03f2e8 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java @@ -108,6 +108,7 @@ public static ObjectMapper createJsonLdObjectMapper() { jsonLdModule.configure(cz.cvut.kbss.jsonld.ConfigParam.SCAN_PACKAGE, "cz.cvut.kbss.termit"); jsonLdModule.configure(SerializationConstants.FORM, SerializationConstants.FORM_COMPACT_WITH_CONTEXT); mapper.registerModule(jsonLdModule); + mapper.registerModule(new JavaTimeModule()); return mapper; } diff --git a/src/main/java/cz/cvut/kbss/termit/dto/LongRunningTaskDto.java b/src/main/java/cz/cvut/kbss/termit/dto/LongRunningTaskDto.java new file mode 100644 index 000000000..12c7ed1da --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/dto/LongRunningTaskDto.java @@ -0,0 +1,39 @@ +package cz.cvut.kbss.termit.dto; + +import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent; +import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.Serializable; +import java.time.Instant; + +public class LongRunningTaskDto implements Serializable { + + @NotNull + private final String name; + + @NotNull + private final LongRunningTaskStatus.State state; + + @Nullable + private final Instant startedAt; + + public LongRunningTaskDto(LongRunningTaskChangedEvent event) { + this.name = event.getName(); + this.state = event.getStatus().getState(); + this.startedAt = event.getStatus().getStartedAt(); + } + + public @NotNull String getName() { + return name; + } + + public @NotNull LongRunningTaskStatus.State getState() { + return state; + } + + public @Nullable Instant getStartedAt() { + return startedAt; + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java new file mode 100644 index 000000000..0ffb07859 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java @@ -0,0 +1,39 @@ +package cz.cvut.kbss.termit.event; + +import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; +import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.ApplicationEvent; + +import java.util.Objects; + +/** + * Indicates a status change of a long-running task. + */ +public class LongRunningTaskChangedEvent extends ApplicationEvent { + + @NotNull + private final String name; + + @NotNull + private final LongRunningTaskStatus status; + + public LongRunningTaskChangedEvent(@NotNull Object source, final @NotNull LongRunningTask longRunningTask) { + super(source); + this.name = Objects.requireNonNull(longRunningTask.getName()); + this.status = new LongRunningTaskStatus(longRunningTask); + } + + public @NotNull String getName() { + return name; + } + + public @NotNull LongRunningTaskStatus getStatus() { + return status; + } + + @Override + public Object getSource() { + return super.getSource(); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 8695d2082..613e397dc 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -75,7 +75,7 @@ private Optional> getCached(@NotNull URI originVoca } } - @Throttle("{#originVocabularyIri}") + @Throttle(value = "{#originVocabularyIri}", name="vocabularyValidation") @Transactional @Override public @NotNull ThrottledFuture> validate(@NotNull URI originVocabularyIri, @NotNull Collection vocabularyIris) { diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java index 97c72ea77..40c72dbb9 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java @@ -143,7 +143,7 @@ private void loadOverrideRules(Model validationModel, String language) throws IO } } - @Throttle("{#originVocabularyIri}") + @Throttle(value = "{#originVocabularyIri}", name = "vocabularyValidation") @Transactional(readOnly = true) @Override public @NotNull ThrottledFuture> validate(final @NotNull URI originVocabularyIri, final @NotNull Collection vocabularyIris) { diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index 9f7f79fe4..466e59a70 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -439,7 +439,9 @@ public void remove(@NonNull Term term) { * @param term Term to analyze * @param vocabularyIri Identifier of the vocabulary used for analysis */ - @Throttle(value = "{#vocabularyIri, #term.getUri()}", group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyTerm(#vocabulary.getUri(), #term.getUri())") + @Throttle(value = "{#vocabularyIri, #term.getUri()}", + group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyTerm(#vocabulary.getUri(), #term.getUri())", + name="termDefinitionAnalysis") @PreAuthorize("@termAuthorizationService.canModify(#term)") public void analyzeTermDefinition(AbstractTerm term, URI vocabularyIri) { Objects.requireNonNull(term); diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 2eebeff0c..9b3111637 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -305,7 +305,8 @@ public List getChangesOfContent(Vocabulary vocabulary) { */ @Transactional @Throttle(value = "{#vocabulary.getUri()}", - group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyAllTerms(#vocabulary.getUri())") + group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyAllTerms(#vocabulary.getUri())", + name = "allTermsVocabularyAnalysis") @PreAuthorize("@vocabularyAuthorizationService.canModify(#vocabulary)") public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { vocabulary = findRequired(vocabulary.getUri()); // required when throttling @@ -322,7 +323,7 @@ public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { /** * Runs text analysis on definitions of all terms in all vocabularies. */ - @Throttle(group = "T(ThrottleGroupProvider).getTextAnalysisVocabulariesAll()") + @Throttle(group = "T(ThrottleGroupProvider).getTextAnalysisVocabulariesAll()", name = "allVocabulariesAnalysis") @Transactional public void runTextAnalysisOnAllVocabularies() { LOG.debug("Analyzing definitions of all terms in all vocabularies."); diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java index aa2104886..494263979 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java @@ -70,7 +70,7 @@ public AnnotationGenerator(DocumentManager documentManager, TermOccurrenceResolv * @param source Source file of the annotated document */ @Transactional - @Throttle("{source.getUri()}") + @Throttle(value = "{source.getUri()}", name = "documentAnnotationGeneration") public void generateAnnotations(InputStream content, File source) { final TermOccurrenceResolver occurrenceResolver = findResolverFor(source); LOG.debug("Resolving annotations of file {}.", source); diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java index 4884b8493..be8e8c228 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java @@ -88,7 +88,7 @@ public TextAnalysisService(RestTemplate restClient, Configuration config, Docume * @param file File whose content shall be analyzed * @param vocabularyContexts Identifiers of repository contexts containing vocabularies intended for text analysis */ - @Throttle("{#file.getUri()}") + @Throttle(value = "{#file.getUri()}", name = "fileAnalysis") @Transactional public void analyzeFile(File file, Set vocabularyContexts) { Objects.requireNonNull(file); diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java index f0e17d9af..013e43c19 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java @@ -1,6 +1,7 @@ package cz.cvut.kbss.termit.util.longrunning; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.time.Instant; import java.util.Optional; @@ -10,6 +11,9 @@ */ public interface LongRunningTask { + @Nullable + String getName(); + /** * @return true when the task is being actively executed, false otherwise. */ @@ -17,7 +21,7 @@ public interface LongRunningTask { /** * Returns {@code true} if this task completed. - * + *

    * Completion may be due to normal termination, an exception, or * cancellation -- in all of these cases, this method will return * {@code true}. diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java deleted file mode 100644 index aba93c00d..000000000 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskRegister.java +++ /dev/null @@ -1,18 +0,0 @@ -package cz.cvut.kbss.termit.util.longrunning; - -import org.jetbrains.annotations.NotNull; - -import java.util.Collection; - -/** - * An object that will schedule a long-running tasks - * @see LongRunningTask - */ -public interface LongRunningTaskRegister { - - /** - * @return pending and currently running tasks - */ - @NotNull - Collection getTasks(); -} diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java new file mode 100644 index 000000000..1570d3b80 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java @@ -0,0 +1,23 @@ +package cz.cvut.kbss.termit.util.longrunning; + +import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.ApplicationEventPublisher; + +/** + * An object that will schedule a long-running tasks + * @see LongRunningTask + */ +public abstract class LongRunningTaskScheduler { + protected final ApplicationEventPublisher eventPublisher; + + protected LongRunningTaskScheduler(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + protected final void notifyTaskChanged(final @NotNull LongRunningTask task) { + if (task.getName() != null && !task.getName().isBlank()) { + eventPublisher.publishEvent(new LongRunningTaskChangedEvent(this, task)); + } + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java new file mode 100644 index 000000000..5e4b39cee --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java @@ -0,0 +1,52 @@ +package cz.cvut.kbss.termit.util.longrunning; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.Serializable; +import java.time.Instant; + +public class LongRunningTaskStatus implements Serializable { + + private final State state; + + private final @Nullable Instant startedAt; + + public LongRunningTaskStatus(LongRunningTask task) { + this.startedAt = task.startedAt().orElse(null); + this.state = State.of(task); + } + + public State getState() { + return state; + } + + public @Nullable Instant getStartedAt() { + return startedAt; + } + + public enum State { + PENDING, RUNNING, DONE; + public static State of(@NotNull LongRunningTask task) { + if (task.isRunning()) { + return RUNNING; + } else if (task.isDone()) { + return DONE; + } else { + return PENDING; + } + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("{") + .append(this.state.name()); + if (startedAt != null) { + builder.append(", startedAt=").append(startedAt); + } + builder.append("}"); + return builder.toString(); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java new file mode 100644 index 000000000..a1027fdfe --- /dev/null +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java @@ -0,0 +1,43 @@ +package cz.cvut.kbss.termit.util.longrunning; + +import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnmodifiableView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class LongRunningTasksRegistry { + + private static final Logger LOG = LoggerFactory.getLogger(LongRunningTasksRegistry.class); + + private final ConcurrentHashMap registry = new ConcurrentHashMap<>(); + + @EventListener + public void onTaskChanged(LongRunningTaskChangedEvent event) { + if (LOG.isTraceEnabled()) { + synchronized (LongRunningTasksRegistry.class) { + LOG.atTrace().setMessage("Long running task changed state: {}{}").addArgument(event::getName) + .addArgument(event::getStatus).log(); + } + } + + if(event.getStatus().getState() == LongRunningTaskStatus.State.DONE) { + registry.remove(event.getName()); + } else { + registry.put(event.getName(), event.getStatus()); + } + } + + @NotNull + @UnmodifiableView + public Map getTasks() { + return Collections.unmodifiableMap(registry); + } +} diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java index 6a2b2890e..f76b1ca64 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java @@ -2,6 +2,7 @@ import cz.cvut.kbss.termit.util.Constants; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -95,4 +96,6 @@ * @see String#compareTo(String) */ @NotNull String group() default ""; + + @Nullable String name() default ""; } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index c76bba9ba..ac3ff81a0 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -5,8 +5,7 @@ import cz.cvut.kbss.termit.exception.ThrottleAspectException; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Pair; -import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; -import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskRegister; +import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskScheduler; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; @@ -16,6 +15,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Scope; import org.springframework.core.annotation.Order; @@ -65,7 +65,7 @@ @Scope(SCOPE_SINGLETON) @Component("throttleAspect") @Profile("!test") -public class ThrottleAspect implements LongRunningTaskRegister { +public class ThrottleAspect extends LongRunningTaskScheduler { private static final Logger LOG = LoggerFactory.getLogger(ThrottleAspect.class); @@ -131,7 +131,10 @@ public class ThrottleAspect implements LongRunningTaskRegister { private final @NotNull AtomicReference<@NotNull Instant> lastClear; @Autowired - public ThrottleAspect(@Qualifier("longRunningTaskScheduler") TaskScheduler taskScheduler, SynchronousTransactionExecutor transactionExecutor) { + public ThrottleAspect(@Qualifier("longRunningTaskScheduler") TaskScheduler taskScheduler, + SynchronousTransactionExecutor transactionExecutor, + ApplicationEventPublisher eventPublisher) { + super(eventPublisher); this.taskScheduler = taskScheduler; this.transactionExecutor = transactionExecutor; throttledFutures = new HashMap<>(); @@ -148,7 +151,9 @@ public ThrottleAspect(@Qualifier("longRunningTaskScheduler") TaskScheduler taskS protected ThrottleAspect(Map> throttledFutures, Map lastRun, NavigableMap> scheduledFutures, TaskScheduler taskScheduler, - Clock clock, SynchronousTransactionExecutor transactionExecutor) { + Clock clock, SynchronousTransactionExecutor transactionExecutor, + ApplicationEventPublisher eventPublisher) { + super(eventPublisher); this.throttledFutures = throttledFutures; this.lastRun = lastRun; this.scheduledFutures = scheduledFutures; @@ -189,7 +194,7 @@ private static StandardEvaluationContext makeDefaultContext() { final Object result = joinPoint.proceed(); if (result instanceof ThrottledFuture throttledFuture) { // directly run throttled future - throttledFuture.run(); + throttledFuture.run(null); return throttledFuture; } return result; @@ -240,10 +245,9 @@ private static StandardEvaluationContext makeDefaultContext() { // acquire a throttled future from a map, or make a new one ThrottledFuture oldThrottledFuture = throttledFutures.getOrDefault(identifier, new ThrottledFuture<>()); - Pair> pair = getFutureTask(joinPoint, identifier, oldThrottledFuture); - Runnable task = pair.getFirst(); + final Pair> pair = getFutureTask(joinPoint, identifier, oldThrottledFuture); ThrottledFuture future = pair.getSecond(); - // update the throttled future in the map, it might be just the same future, but it might be a new one + future.setName(throttleAnnotation.name()); // update the throttled future in the map, it might be just the same future, but it might be a new one synchronized (throttledFutures) { throttledFutures.put(identifier, future); @@ -259,10 +263,10 @@ private static StandardEvaluationContext makeDefaultContext() { boolean oldFutureIsDone = oldScheduledFuture == null || oldScheduledFuture.isDone(); if (oldThrottledFuture != future) { oldThrottledFuture.then(ignored -> - schedule(identifier, task, throttleExpired && oldFutureIsDone) + schedule(identifier, pair, throttleExpired && oldFutureIsDone) ); } else { - schedule(identifier, task, throttleExpired && oldFutureIsDone); + schedule(identifier, pair, throttleExpired && oldFutureIsDone); } } @@ -383,15 +387,16 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id try { // fulfill the future if (withTransaction) { - transactionExecutor.execute(throttledFuture::run); + transactionExecutor.execute(()->throttledFuture.run(this::notifyTaskChanged)); } else { - throttledFuture.run(); + throttledFuture.run(this::notifyTaskChanged); } // update last run timestamp synchronized (lastRun) { lastRun.put(identifier, Instant.now(clock)); } } finally { + notifyTaskChanged(throttledFuture); // task done // clear the security context SecurityContextHolder.clearContext(); LOG.trace("Finished throttled task [{} left] [{} running] '{}'", countRemaining(), countRunning() - 1, identifier); @@ -463,16 +468,17 @@ private boolean isThresholdExpired(Identifier identifier) { } @SuppressWarnings("unchecked") - private void schedule(Identifier identifier, Runnable task, boolean immediately) { + private void schedule(Identifier identifier, Pair> future, boolean immediately) { Instant startTime = Instant.now(clock).plus(THROTTLE_THRESHOLD); if (immediately) { startTime = Instant.now(clock); } synchronized (scheduledFutures) { - Future scheduled = taskScheduler.schedule(task, startTime); + Future scheduled = taskScheduler.schedule(future.getFirst(), startTime); // casting the type parameter to Object scheduledFutures.put(identifier, (Future) scheduled); } + notifyTaskChanged(future.getSecond()); // task scheduled } private void cancelWithHigherGroup(Identifier throttleAnnotation) { @@ -557,13 +563,6 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati } } - @Override - public @NotNull Collection getTasks() { - synchronized (throttledFutures) { - return throttledFutures.values().stream().filter(f -> !f.isDone()).map(f -> (LongRunningTask) f).toList(); - } - } - /** * A composed identifier of a throttled instance. *
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java
    index d2de332c1..abc79051a 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java
    @@ -34,6 +34,8 @@ public class ThrottledFuture implements CacheableFuture, LongRunningTask {
     
         private final AtomicReference<@Nullable Instant> startedAt = new AtomicReference<>(null);
     
    +    private @Nullable String name = null;
    +
         private ThrottledFuture(@NotNull final Supplier task) {
             this.task = task;
             future = new CompletableFuture<>();
    @@ -68,7 +70,7 @@ public static  ThrottledFuture canceled() {
          */
         public static  ThrottledFuture done(T result) {
             ThrottledFuture f = ThrottledFuture.of(() -> result);
    -        f.run();
    +        f.run(null);
             return f;
         }
     
    @@ -169,7 +171,11 @@ protected ThrottledFuture transfer(ThrottledFuture target) {
             }
         }
     
    -    protected void run() {
    +    /**
    +     * Executes the task associated with this future
    +     * @param startedCallback called once {@link #startedAt} is set and so execution is considered as running.
    +     */
    +    protected void run(@Nullable Consumer> startedCallback) {
             boolean locked = false;
             try {
                 do {
    @@ -180,7 +186,11 @@ protected void run() {
                         Thread.yield();
                     }
                 } while (!locked);
    +
                 startedAt.set(Utils.timestamp());
    +            if (startedCallback != null) {
    +                startedCallback.accept(this);
    +            }
     
                 T result = null;
                 if (task != null) {
    @@ -198,6 +208,15 @@ protected void run() {
             }
         }
     
    +    @Override
    +    public @Nullable String getName() {
    +        return this.name;
    +    }
    +
    +    protected void setName(@Nullable String name) {
    +        this.name = name;
    +    }
    +
         @Override
         public boolean isRunning() {
             return startedAt.get() != null && !isDone();
    diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java
    new file mode 100644
    index 000000000..99b5f6107
    --- /dev/null
    +++ b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java
    @@ -0,0 +1,27 @@
    +package cz.cvut.kbss.termit.websocket;
    +
    +import cz.cvut.kbss.termit.dto.LongRunningTaskDto;
    +import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent;
    +import cz.cvut.kbss.termit.security.SecurityConstants;
    +import cz.cvut.kbss.termit.service.IdentifierResolver;
    +import cz.cvut.kbss.termit.util.Configuration;
    +import org.springframework.context.event.EventListener;
    +import org.springframework.messaging.handler.annotation.MessageMapping;
    +import org.springframework.messaging.simp.SimpMessagingTemplate;
    +import org.springframework.security.access.prepost.PreAuthorize;
    +import org.springframework.stereotype.Controller;
    +
    +@Controller
    +@MessageMapping("/long-running-tasks")
    +@PreAuthorize("hasRole('" + SecurityConstants.ROLE_RESTRICTED_USER + "')")
    +public class LongRunningTasksWebSocketController extends BaseWebSocketController {
    +    protected LongRunningTasksWebSocketController(IdentifierResolver idResolver, Configuration config,
    +                                                  SimpMessagingTemplate messagingTemplate) {
    +        super(idResolver, config, messagingTemplate);
    +    }
    +
    +    @EventListener
    +    public void onTaskChanged(LongRunningTaskChangedEvent event) {
    +        messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, new LongRunningTaskDto(event));
    +    }
    +}
    diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/WebSocketDestinations.java b/src/main/java/cz/cvut/kbss/termit/websocket/WebSocketDestinations.java
    index ee8d088e3..c5a525347 100644
    --- a/src/main/java/cz/cvut/kbss/termit/websocket/WebSocketDestinations.java
    +++ b/src/main/java/cz/cvut/kbss/termit/websocket/WebSocketDestinations.java
    @@ -19,6 +19,11 @@ public final class WebSocketDestinations {
          */
         public static final String VOCABULARIES_TEXT_ANALYSIS_FINISHED_TERM_DEFINITION = VOCABULARIES_TEXT_ANALYSIS_FINISHED + "/term-definition";
     
    +    /**
    +     * Used for pushing updates about long-running tasks to clients
    +     */
    +    public static final String LONG_RUNNING_TASKS_UPDATE = "/long-running-tasks/update";
    +
         private WebSocketDestinations() {
             throw new AssertionError();
         }
    diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java
    index 68d6b4a5e..277c26714 100644
    --- a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java
    +++ b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java
    @@ -58,12 +58,10 @@
     import org.springframework.web.multipart.MultipartFile;
     
     import java.io.File;
    -import java.net.URI;
     import java.nio.charset.StandardCharsets;
     import java.util.Arrays;
     import java.util.Collections;
     import java.util.List;
    -import java.util.Map;
     import java.util.Optional;
     
     import static cz.cvut.kbss.termit.environment.Environment.termsToDtos;
    diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java
    index 1286c8c35..69f8430b3 100644
    --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java
    +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java
    @@ -1,6 +1,7 @@
     package cz.cvut.kbss.termit.util.throttle;
     
     import org.jetbrains.annotations.NotNull;
    +import org.jetbrains.annotations.Nullable;
     
     import java.lang.annotation.Annotation;
     
    @@ -28,6 +29,11 @@ public MockedThrottle(@NotNull String value, @NotNull String group) {
             return group;
         }
     
    +    @Override
    +    public @Nullable String name() {
    +        return "NameOfMockedThrottle"+group+value;
    +    }
    +
         @Override
         public Class annotationType() {
             return Throttle.class;
    diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/TestFutureRunner.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/TestFutureRunner.java
    index b715c32c8..ce9b1e70c 100644
    --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/TestFutureRunner.java
    +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/TestFutureRunner.java
    @@ -14,7 +14,7 @@ private TestFutureRunner() {
          * @implNote Note that this method is intended only for testing purposes.
          */
         public static  T runFuture(ThrottledFuture future) {
    -        future.run();
    +        future.run(null);
             try {
                 return future.get();
             } catch (InterruptedException | ExecutionException e) {
    diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java
    index 81b9a9438..ac23c5d12 100644
    --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java
    +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java
    @@ -3,12 +3,12 @@
     import com.vladsch.flexmark.util.collection.OrderedMap;
     import cz.cvut.kbss.termit.exception.TermItException;
     import cz.cvut.kbss.termit.exception.ThrottleAspectException;
    -import cz.cvut.kbss.termit.util.longrunning.LongRunningTask;
     import org.aspectj.lang.ProceedingJoinPoint;
     import org.junit.jupiter.api.BeforeEach;
     import org.junit.jupiter.api.Test;
     import org.junit.jupiter.params.ParameterizedTest;
     import org.junit.jupiter.params.provider.ValueSource;
    +import org.springframework.context.ApplicationEventPublisher;
     import org.springframework.expression.spel.SpelParseException;
     import org.springframework.scheduling.TaskScheduler;
     import org.springframework.scheduling.support.TaskUtils;
    @@ -19,7 +19,6 @@
     import java.time.Instant;
     import java.time.ZoneId;
     import java.time.temporal.ChronoUnit;
    -import java.util.Collection;
     import java.util.List;
     import java.util.Map;
     import java.util.NavigableMap;
    @@ -42,10 +41,8 @@
     import static org.junit.jupiter.api.Assertions.assertEquals;
     import static org.junit.jupiter.api.Assertions.assertFalse;
     import static org.junit.jupiter.api.Assertions.assertInstanceOf;
    -import static org.junit.jupiter.api.Assertions.assertIterableEquals;
     import static org.junit.jupiter.api.Assertions.assertNotEquals;
     import static org.junit.jupiter.api.Assertions.assertNotNull;
    -import static org.junit.jupiter.api.Assertions.assertNotSame;
     import static org.junit.jupiter.api.Assertions.assertThrows;
     import static org.junit.jupiter.api.Assertions.assertTrue;
     import static org.junit.jupiter.api.Assertions.fail;
    @@ -142,6 +139,8 @@ class ThrottleAspectTest {
          */
         ProceedingJoinPoint joinPointC;
     
    +    ApplicationEventPublisher eventPublisher;
    +
         Clock clock = Clock.fixed(Instant.now(), ZoneId.of("UTC"));
     
         void mockA() throws Throwable {
    @@ -206,8 +205,9 @@ void beforeEach() throws Throwable {
             when(mockedClock.instant()).then(invocation -> getInstant());
     
             transactionExecutor = spy(SynchronousTransactionExecutor.class);
    +        eventPublisher = mock(ApplicationEventPublisher.class);
     
    -        sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock, transactionExecutor);
    +        sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock, transactionExecutor, eventPublisher);
         }
     
         /**
    @@ -780,18 +780,6 @@ void resolvedFutureFromMethodIsReturnedWithoutSchedule() throws Throwable {
             assertTrue(taskSchedulerTasks.isEmpty());
         }
     
    -    @Test
    -    void getTasksReturnsCopyOfThrottledFutures() throws Throwable {
    -        sut.throttleMethodCall(joinPointA, throttleA);
    -        sut.throttleMethodCall(joinPointB, throttleB);
    -        sut.throttleMethodCall(joinPointC, throttleC);
    -
    -        final Collection tasks = sut.getTasks();
    -        // assert a COPY returned, so they are not the same
    -        assertNotSame(throttledFutures.values(), tasks);
    -        assertIterableEquals(throttledFutures.values(), tasks);
    -    }
    -
         @Test
         void aspectThrowsOnMalformedSpel() {
             throttleA.setValue("invalid spel expression");
    @@ -824,7 +812,7 @@ void aspectDownNotThrowsOnEmptyGroup() {
     
         @Test
         void aspectConstructsFromAutowiredConstructor() {
    -        assertDoesNotThrow(() -> new ThrottleAspect(taskScheduler, transactionExecutor));
    +        assertDoesNotThrow(() -> new ThrottleAspect(taskScheduler, transactionExecutor, eventPublisher));
         }
     
         @Test
    diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java
    index 33e76fb00..ff4f66f72 100644
    --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java
    +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java
    @@ -90,7 +90,7 @@ void thenActionIsExecutedSynchronouslyWhenFutureIsAlreadyDoneAndNotCanceled() {
             final ThrottledFuture future = ThrottledFuture.of(() -> result);
             final AtomicBoolean completed = new AtomicBoolean(false);
             final AtomicReference futureResult = new AtomicReference<>(null);
    -        future.run();
    +        future.run(null);
             assertTrue(future.isDone());
             assertFalse(future.isCancelled());
             future.then(fResult -> {
    @@ -123,7 +123,7 @@ void thenActionIsExecutedOnceFutureIsRun() {
             });
             assertNull(fResult.get());
             assertFalse(completed.get()); // action was not executed yet
    -        future.run();
    +        future.run(null);
             assertTrue(completed.get());
             assertEquals(result, fResult.get());
         }
    @@ -146,14 +146,14 @@ void callingRunWillExecuteFutureOnlyOnce() {
                 count.incrementAndGet();
             });
     
    -        future.run();
    +        future.run(null);
             final Optional runningSince = future.startedAt();
             assertTrue(runningSince.isPresent());
             assertTrue(future.isDone());
             assertFalse(future.isCancelled());
             assertFalse(future.isRunning());
     
    -        future.run();
    +        future.run(null);
             assertTrue(future.isDone());
             assertFalse(future.isCancelled());
             assertFalse(future.isRunning());
    @@ -176,8 +176,8 @@ void callingRunWillExecuteFutureOnlyOnceAndWontBlockSecondThreadAsync() throws T
                     Thread.yield();
                 }
             });
    -        final Thread threadA = new Thread(future::run);
    -        final Thread threadB = new Thread(future::run);
    +        final Thread threadA = new Thread(() -> future.run(null));
    +        final Thread threadB = new Thread(() -> future.run(null));
             threadA.start();
     
             await("count incrementation").atMost(Duration.ofSeconds(30)).until(() -> count.get() > 0);
    @@ -242,7 +242,7 @@ void getNowReturnsFutureResultWhenItsDoneAndNotCancelled() {
             final String futureResult = "future";
             final String cachedResult = "cached";
             ThrottledFuture future = ThrottledFuture.of(() -> futureResult).setCachedResult(cachedResult);
    -        future.run();
    +        future.run(null);
     
             Optional result = future.getNow();
             assertTrue(result.isPresent());
    @@ -266,7 +266,7 @@ void onCompletionCallbacksAreNotExecutedWhenTaskIsNull() {
             final AtomicBoolean callbackExecuted = new AtomicBoolean(false);
             final ThrottledFuture future = new ThrottledFuture<>();
             future.then(ignored -> callbackExecuted.set(true));
    -        future.run();
    +        future.run(null);
             assertFalse(callbackExecuted.get());
         }
     
    @@ -429,4 +429,19 @@ void updateReturnsNewFutureWhenLockIsNotLockedForUpdate() throws Throwable {
             final ThrottledFuture result = future.update(()->"", List.of());
             assertNotEquals(future, result);
         }
    +
    +    @Test
    +    void runExecutionCallbackIsExecutedAfterStartedAtIsSetAndBeforeTaskExecution() {
    +        final AtomicBoolean taskExecuted = new AtomicBoolean(false);
    +        final ThrottledFuture future = ThrottledFuture.of(()->{
    +            taskExecuted.set(true);
    +        });
    +
    +        future.run(f -> {
    +            assertEquals(future, f);
    +            assertTrue(f.startedAt().isPresent());
    +        });
    +
    +        assertTrue(taskExecuted.get());
    +    }
     }
    
    From c2f9bac110195b578ddb2f095c23f4f7a18f054a Mon Sep 17 00:00:00 2001
    From: lukaskabc 
    Date: Wed, 11 Sep 2024 07:25:34 +0200
    Subject: [PATCH 115/150] [Performance #285] Fix test error due to new
     controller missing dependency
    
    ---
     .../termit/websocket/BaseWebSocketControllerTestRunner.java | 6 ++++++
     .../termit/websocket/VocabularySocketControllerTest.java    | 5 +----
     2 files changed, 7 insertions(+), 4 deletions(-)
    
    diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java
    index 1a9ae8bd2..c6e737cab 100644
    --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java
    +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java
    @@ -2,6 +2,7 @@
     
     import cz.cvut.kbss.termit.environment.config.TestRestSecurityConfig;
     import cz.cvut.kbss.termit.environment.config.TestWebSocketConfig;
    +import cz.cvut.kbss.termit.service.IdentifierResolver;
     import cz.cvut.kbss.termit.util.Configuration;
     import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler;
     import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler;
    @@ -9,6 +10,7 @@
     import org.junit.jupiter.api.AfterEach;
     import org.junit.jupiter.api.BeforeEach;
     import org.junit.jupiter.api.extension.ExtendWith;
    +import org.mockito.Mock;
     import org.mockito.junit.jupiter.MockitoExtension;
     import org.slf4j.Logger;
     import org.slf4j.LoggerFactory;
    @@ -16,6 +18,7 @@
     import org.springframework.beans.factory.annotation.Qualifier;
     import org.springframework.boot.context.properties.EnableConfigurationProperties;
     import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
    +import org.springframework.boot.test.mock.mockito.MockBean;
     import org.springframework.boot.test.mock.mockito.SpyBean;
     import org.springframework.messaging.Message;
     import org.springframework.messaging.support.AbstractSubscribableChannel;
    @@ -48,6 +51,9 @@ public abstract class BaseWebSocketControllerTestRunner {
         @SpyBean
         protected StompExceptionHandler stompExceptionHandler;
     
    +    @MockBean
    +    protected IdentifierResolver identifierResolver;
    +
         /**
          * Simulated messages from client to server
          */
    diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java
    index cd917a1d4..7e36c462f 100644
    --- a/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java
    +++ b/src/test/java/cz/cvut/kbss/termit/websocket/VocabularySocketControllerTest.java
    @@ -31,9 +31,6 @@
     
     class VocabularySocketControllerTest extends BaseWebSocketControllerTestRunner {
     
    -    @MockBean
    -    IdentifierResolver idResolver;
    -
         @MockBean
         VocabularyService vocabularyService;
     
    @@ -53,7 +50,7 @@ public void setup() {
             vocabulary = Generator.generateVocabularyWithId();
             fragment = IdentifierResolver.extractIdentifierFragment(vocabulary.getUri()).substring(1);
             namespace = vocabulary.getUri().toString().substring(0, vocabulary.getUri().toString().lastIndexOf('/'));
    -        when(idResolver.resolveIdentifier(namespace, fragment)).thenReturn(vocabulary.getUri());
    +        when(identifierResolver.resolveIdentifier(namespace, fragment)).thenReturn(vocabulary.getUri());
             when(vocabularyService.getReference(vocabulary.getUri())).thenReturn(vocabulary);
             when(vocabularyService.validateContents(vocabulary.getUri())).thenReturn(ThrottledFuture.done(List.of()));
     
    
    From b26f13e6ddd04021bd52243ae269a0354a50e484 Mon Sep 17 00:00:00 2001
    From: lukaskabc 
    Date: Wed, 11 Sep 2024 11:20:21 +0200
    Subject: [PATCH 116/150] [Performance #285] Sending current running tasks to a
     client on subscription
    
    ---
     .../kbss/termit/dto/LongRunningTaskDto.java   | 39 ----------------
     .../event/LongRunningTaskChangedEvent.java    | 15 -------
     .../util/longrunning/LongRunningTask.java     |  4 ++
     .../longrunning/LongRunningTaskStatus.java    | 45 +++++++++++++------
     .../longrunning/LongRunningTasksRegistry.java | 11 ++---
     .../termit/util/throttle/ThrottledFuture.java |  8 ++++
     .../websocket/BaseWebSocketController.java    |  4 ++
     .../LongRunningTasksWebSocketController.java  | 23 ++++++++--
     .../BaseWebSocketControllerTestRunner.java    |  5 ++-
     .../BaseWebSocketIntegrationTestRunner.java   |  5 +++
     10 files changed, 83 insertions(+), 76 deletions(-)
     delete mode 100644 src/main/java/cz/cvut/kbss/termit/dto/LongRunningTaskDto.java
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/dto/LongRunningTaskDto.java b/src/main/java/cz/cvut/kbss/termit/dto/LongRunningTaskDto.java
    deleted file mode 100644
    index 12c7ed1da..000000000
    --- a/src/main/java/cz/cvut/kbss/termit/dto/LongRunningTaskDto.java
    +++ /dev/null
    @@ -1,39 +0,0 @@
    -package cz.cvut.kbss.termit.dto;
    -
    -import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent;
    -import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskStatus;
    -import org.jetbrains.annotations.NotNull;
    -import org.jetbrains.annotations.Nullable;
    -
    -import java.io.Serializable;
    -import java.time.Instant;
    -
    -public class LongRunningTaskDto implements Serializable {
    -
    -    @NotNull
    -    private final String name;
    -
    -    @NotNull
    -    private final LongRunningTaskStatus.State state;
    -
    -    @Nullable
    -    private final Instant startedAt;
    -
    -    public LongRunningTaskDto(LongRunningTaskChangedEvent event) {
    -        this.name = event.getName();
    -        this.state = event.getStatus().getState();
    -        this.startedAt = event.getStatus().getStartedAt();
    -    }
    -
    -    public @NotNull String getName() {
    -        return name;
    -    }
    -
    -    public @NotNull LongRunningTaskStatus.State getState() {
    -        return state;
    -    }
    -
    -    public @Nullable Instant getStartedAt() {
    -        return startedAt;
    -    }
    -}
    diff --git a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java
    index 0ffb07859..2c5fbeb5e 100644
    --- a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java
    +++ b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java
    @@ -5,35 +5,20 @@
     import org.jetbrains.annotations.NotNull;
     import org.springframework.context.ApplicationEvent;
     
    -import java.util.Objects;
    -
     /**
      * Indicates a status change of a long-running task.
      */
     public class LongRunningTaskChangedEvent extends ApplicationEvent {
     
    -    @NotNull
    -    private final String name;
    -
         @NotNull
         private final LongRunningTaskStatus status;
     
         public LongRunningTaskChangedEvent(@NotNull Object source, final @NotNull LongRunningTask longRunningTask) {
             super(source);
    -        this.name = Objects.requireNonNull(longRunningTask.getName());
             this.status = new LongRunningTaskStatus(longRunningTask);
         }
     
    -    public @NotNull String getName() {
    -        return name;
    -    }
    -
         public @NotNull LongRunningTaskStatus getStatus() {
             return status;
         }
    -
    -    @Override
    -    public Object getSource() {
    -        return super.getSource();
    -    }
     }
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java
    index 013e43c19..025cd51b8 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java
    @@ -5,6 +5,7 @@
     
     import java.time.Instant;
     import java.util.Optional;
    +import java.util.UUID;
     
     /**
      * An asynchronously running task that is expected to run for some time.
    @@ -36,4 +37,7 @@ public interface LongRunningTask {
          */
         @NotNull
         Optional startedAt();
    +
    +    @NotNull
    +    UUID getUuid();
     }
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java
    index 5e4b39cee..a5584ef54 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java
    @@ -5,16 +5,31 @@
     
     import java.io.Serializable;
     import java.time.Instant;
    +import java.util.Objects;
    +import java.util.UUID;
     
     public class LongRunningTaskStatus implements Serializable {
     
    +    @NotNull
    +    private final String name;
    +
    +    @NotNull
    +    private final UUID uuid;
    +
         private final State state;
     
         private final @Nullable Instant startedAt;
     
    -    public LongRunningTaskStatus(LongRunningTask task) {
    +    public LongRunningTaskStatus(@NotNull LongRunningTask task) {
    +        Objects.requireNonNull(task.getName());
    +        this.name = task.getName();
             this.startedAt = task.startedAt().orElse(null);
             this.state = State.of(task);
    +        this.uuid = task.getUuid();
    +    }
    +
    +    public @NotNull String getName() {
    +        return name;
         }
     
         public State getState() {
    @@ -25,8 +40,24 @@ public State getState() {
             return startedAt;
         }
     
    +    public @NotNull UUID getUuid() {
    +        return uuid;
    +    }
    +
    +    @Override
    +    public String toString() {
    +        StringBuilder builder = new StringBuilder();
    +        builder.append("{").append(this.state.name());
    +        if (startedAt != null) {
    +            builder.append(", startedAt=").append(startedAt);
    +        }
    +        builder.append("}");
    +        return builder.toString();
    +    }
    +
         public enum State {
             PENDING, RUNNING, DONE;
    +
             public static State of(@NotNull LongRunningTask task) {
                 if (task.isRunning()) {
                     return RUNNING;
    @@ -37,16 +68,4 @@ public static State of(@NotNull LongRunningTask task) {
                 }
             }
         }
    -
    -    @Override
    -    public String toString() {
    -        StringBuilder builder = new StringBuilder();
    -        builder.append("{")
    -                .append(this.state.name());
    -        if (startedAt != null) {
    -            builder.append(", startedAt=").append(startedAt);
    -        }
    -        builder.append("}");
    -        return builder.toString();
    -    }
     }
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java
    index a1027fdfe..67d7cd398 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java
    @@ -21,17 +21,18 @@ public class LongRunningTasksRegistry {
     
         @EventListener
         public void onTaskChanged(LongRunningTaskChangedEvent event) {
    +        final LongRunningTaskStatus status = event.getStatus();
             if (LOG.isTraceEnabled()) {
                 synchronized (LongRunningTasksRegistry.class) {
    -                LOG.atTrace().setMessage("Long running task changed state: {}{}").addArgument(event::getName)
    -                   .addArgument(event::getStatus).log();
    +                LOG.atTrace().setMessage("Long running task changed state: {}{}").addArgument(status::getName)
    +                   .addArgument(status).log();
                 }
             }
     
    -        if(event.getStatus().getState() == LongRunningTaskStatus.State.DONE) {
    -            registry.remove(event.getName());
    +        if(status.getState() == LongRunningTaskStatus.State.DONE) {
    +            registry.remove(status.getName());
             } else {
    -            registry.put(event.getName(), event.getStatus());
    +            registry.put(status.getName(), status);
             }
         }
     
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java
    index abc79051a..1eb73f227 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java
    @@ -10,6 +10,7 @@
     import java.util.ArrayList;
     import java.util.List;
     import java.util.Optional;
    +import java.util.UUID;
     import java.util.concurrent.CompletableFuture;
     import java.util.concurrent.ExecutionException;
     import java.util.concurrent.TimeUnit;
    @@ -24,6 +25,8 @@ public class ThrottledFuture implements CacheableFuture, LongRunningTask {
         private final ReentrantLock lock = new ReentrantLock();
         private final ReentrantLock callbackLock = new ReentrantLock();
     
    +    private final UUID uuid = UUID.randomUUID();
    +
         private @Nullable T cachedResult = null;
     
         private final CompletableFuture future;
    @@ -227,6 +230,11 @@ public boolean isRunning() {
             return Optional.ofNullable(startedAt.get());
         }
     
    +    @Override
    +    public @NotNull UUID getUuid() {
    +        return uuid;
    +    }
    +
         @Override
         public ThrottledFuture then(Consumer action) {
             try {
    diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java
    index 7fe6112a3..8f41db137 100644
    --- a/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java
    +++ b/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java
    @@ -10,12 +10,15 @@
     import org.springframework.messaging.simp.stomp.StompCommand;
     import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
     import org.springframework.messaging.simp.user.DestinationUserNameProvider;
    +import org.springframework.util.LinkedMultiValueMap;
     
     import java.security.Principal;
     import java.util.Map;
     import java.util.Objects;
     import java.util.Optional;
     
    +import static org.springframework.messaging.support.NativeMessageHeaderAccessor.NATIVE_HEADERS;
    +
     public class BaseWebSocketController extends BaseController {
     
         protected final SimpMessagingTemplate messagingTemplate;
    @@ -41,6 +44,7 @@ protected void sendToSession(@NotNull String destination, @NotNull Object payloa
                     .ifPresentOrElse(sessionId -> { // session id present
                                 StompHeaderAccessor headerAccessor = StompHeaderAccessor.create(StompCommand.MESSAGE);
                                 // add reply headers as native headers
    +                            headerAccessor.setHeader(NATIVE_HEADERS, new LinkedMultiValueMap<>(replyHeaders.size()));
                                 replyHeaders.forEach((name, value) -> headerAccessor.addNativeHeader(name, Objects.toString(value)));
                                 headerAccessor.setSessionId(sessionId); // pass session id to new headers
                                 // send to user session
    diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java
    index 99b5f6107..97aee6487 100644
    --- a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java
    +++ b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java
    @@ -1,27 +1,44 @@
     package cz.cvut.kbss.termit.websocket;
     
    -import cz.cvut.kbss.termit.dto.LongRunningTaskDto;
     import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent;
     import cz.cvut.kbss.termit.security.SecurityConstants;
     import cz.cvut.kbss.termit.service.IdentifierResolver;
     import cz.cvut.kbss.termit.util.Configuration;
    +import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry;
    +import org.jetbrains.annotations.NotNull;
     import org.springframework.context.event.EventListener;
    +import org.springframework.messaging.MessageHeaders;
     import org.springframework.messaging.handler.annotation.MessageMapping;
     import org.springframework.messaging.simp.SimpMessagingTemplate;
    +import org.springframework.messaging.simp.annotation.SubscribeMapping;
     import org.springframework.security.access.prepost.PreAuthorize;
     import org.springframework.stereotype.Controller;
     
    +import java.util.Map;
    +
     @Controller
     @MessageMapping("/long-running-tasks")
     @PreAuthorize("hasRole('" + SecurityConstants.ROLE_RESTRICTED_USER + "')")
     public class LongRunningTasksWebSocketController extends BaseWebSocketController {
    +
    +    private final LongRunningTasksRegistry registry;
    +
         protected LongRunningTasksWebSocketController(IdentifierResolver idResolver, Configuration config,
    -                                                  SimpMessagingTemplate messagingTemplate) {
    +                                                  SimpMessagingTemplate messagingTemplate,
    +                                                  LongRunningTasksRegistry registry) {
             super(idResolver, config, messagingTemplate);
    +        this.registry = registry;
    +    }
    +
    +    @SubscribeMapping("/update")
    +    public void tasksRequest(@NotNull MessageHeaders messageHeaders) {
    +        sendToSession(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, "", Map.of("reset", true), messageHeaders);
    +        registry.getTasks().values()
    +                .forEach(status -> sendToSession(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, status, Map.of(), messageHeaders));
         }
     
         @EventListener
         public void onTaskChanged(LongRunningTaskChangedEvent event) {
    -        messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, new LongRunningTaskDto(event));
    +        messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, event.getStatus());
         }
     }
    diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java
    index c6e737cab..e2038aa0b 100644
    --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java
    +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketControllerTestRunner.java
    @@ -4,13 +4,13 @@
     import cz.cvut.kbss.termit.environment.config.TestWebSocketConfig;
     import cz.cvut.kbss.termit.service.IdentifierResolver;
     import cz.cvut.kbss.termit.util.Configuration;
    +import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry;
     import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler;
     import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler;
     import cz.cvut.kbss.termit.websocket.util.CachingChannelInterceptor;
     import org.junit.jupiter.api.AfterEach;
     import org.junit.jupiter.api.BeforeEach;
     import org.junit.jupiter.api.extension.ExtendWith;
    -import org.mockito.Mock;
     import org.mockito.junit.jupiter.MockitoExtension;
     import org.slf4j.Logger;
     import org.slf4j.LoggerFactory;
    @@ -54,6 +54,9 @@ public abstract class BaseWebSocketControllerTestRunner {
         @MockBean
         protected IdentifierResolver identifierResolver;
     
    +    @MockBean
    +    protected LongRunningTasksRegistry longRunningTasksRegistry;
    +
         /**
          * Simulated messages from client to server
          */
    diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java
    index f41f42874..a8cd731c5 100644
    --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java
    +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java
    @@ -13,6 +13,7 @@
     import cz.cvut.kbss.termit.security.model.TermItUserDetails;
     import cz.cvut.kbss.termit.service.security.TermItUserDetailsService;
     import cz.cvut.kbss.termit.util.Configuration;
    +import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry;
     import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler;
     import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler;
     import org.jetbrains.annotations.NotNull;
    @@ -27,6 +28,7 @@
     import org.springframework.boot.context.properties.EnableConfigurationProperties;
     import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
     import org.springframework.boot.test.context.SpringBootTest;
    +import org.springframework.boot.test.mock.mockito.MockBean;
     import org.springframework.boot.test.mock.mockito.SpyBean;
     import org.springframework.context.annotation.ComponentScan;
     import org.springframework.context.annotation.EnableAspectJAutoProxy;
    @@ -76,6 +78,9 @@ public abstract class BaseWebSocketIntegrationTestRunner {
         @SpyBean
         protected StompExceptionHandler stompExceptionHandler;
     
    +    @MockBean
    +    protected LongRunningTasksRegistry longRunningTasksRegistry;
    +
         protected WebSocketStompClient stompClient;
     
         @Value("ws://localhost:${local.server.port}/ws")
    
    From 6be374e48d3a8b3e3443841a31639d34e1ec8c68 Mon Sep 17 00:00:00 2001
    From: lukaskabc 
    Date: Wed, 11 Sep 2024 12:06:06 +0200
    Subject: [PATCH 117/150] [Performance #285] add uuid to long running task
     status logging
    
    ---
     .../kbss/termit/util/longrunning/LongRunningTaskStatus.java     | 2 ++
     1 file changed, 2 insertions(+)
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java
    index a5584ef54..9875cd119 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java
    @@ -51,6 +51,8 @@ public String toString() {
             if (startedAt != null) {
                 builder.append(", startedAt=").append(startedAt);
             }
    +        builder.append(", ");
    +        builder.append(uuid);
             builder.append("}");
             return builder.toString();
         }
    
    From 16642b67996a31fd612d4ccaeaf6464fa2fe923d Mon Sep 17 00:00:00 2001
    From: lukaskabc 
    Date: Wed, 11 Sep 2024 12:14:40 +0200
    Subject: [PATCH 118/150] [Performance #285] change when notification about
     running task change is sent
    
    ---
     .../cvut/kbss/termit/util/throttle/ThrottleAspect.java | 10 +++++-----
     1 file changed, 5 insertions(+), 5 deletions(-)
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java
    index ac3ff81a0..b25220f22 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java
    @@ -263,11 +263,12 @@ private static StandardEvaluationContext makeDefaultContext() {
                 boolean oldFutureIsDone = oldScheduledFuture == null || oldScheduledFuture.isDone();
                 if (oldThrottledFuture != future) {
                     oldThrottledFuture.then(ignored ->
    -                    schedule(identifier, pair, throttleExpired && oldFutureIsDone)
    +                    schedule(identifier, pair.getFirst(), throttleExpired && oldFutureIsDone)
                     );
                 } else {
    -                schedule(identifier, pair, throttleExpired && oldFutureIsDone);
    +                schedule(identifier, pair.getFirst(), throttleExpired && oldFutureIsDone);
                 }
    +            notifyTaskChanged(future);
             }
     
             return result;
    @@ -468,17 +469,16 @@ private boolean isThresholdExpired(Identifier identifier) {
         }
     
         @SuppressWarnings("unchecked")
    -    private void schedule(Identifier identifier, Pair> future, boolean immediately) {
    +    private void schedule(Identifier identifier, Runnable task, boolean immediately) {
             Instant startTime = Instant.now(clock).plus(THROTTLE_THRESHOLD);
             if (immediately) {
                 startTime = Instant.now(clock);
             }
             synchronized (scheduledFutures) {
    -            Future scheduled = taskScheduler.schedule(future.getFirst(), startTime);
    +            Future scheduled = taskScheduler.schedule(task, startTime);
                 // casting the type parameter to Object
                 scheduledFutures.put(identifier, (Future) scheduled);
             }
    -        notifyTaskChanged(future.getSecond()); // task scheduled
         }
     
         private void cancelWithHigherGroup(Identifier throttleAnnotation) {
    
    From 13fe4da75a3e883768c1c4f3a1caed825c5fa911 Mon Sep 17 00:00:00 2001
    From: lukaskabc 
    Date: Wed, 11 Sep 2024 12:30:15 +0200
    Subject: [PATCH 119/150] [Performance #285] reset client when all long-running
     tasks completed
    
    ---
     .../event/AllLongRunningTasksCompletedEvent.java   | 13 +++++++++++++
     .../util/longrunning/LongRunningTasksRegistry.java | 14 ++++++++++++++
     .../LongRunningTasksWebSocketController.java       |  6 ++++++
     3 files changed, 33 insertions(+)
     create mode 100644 src/main/java/cz/cvut/kbss/termit/event/AllLongRunningTasksCompletedEvent.java
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/event/AllLongRunningTasksCompletedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/AllLongRunningTasksCompletedEvent.java
    new file mode 100644
    index 000000000..257476bc1
    --- /dev/null
    +++ b/src/main/java/cz/cvut/kbss/termit/event/AllLongRunningTasksCompletedEvent.java
    @@ -0,0 +1,13 @@
    +package cz.cvut.kbss.termit.event;
    +
    +import org.springframework.context.ApplicationEvent;
    +
    +/**
    + * Indicates that all long-running tasks were completed and there is not running or pending task.
    + */
    +public class AllLongRunningTasksCompletedEvent extends ApplicationEvent {
    +
    +    public AllLongRunningTasksCompletedEvent(Object source) {
    +        super(source);
    +    }
    +}
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java
    index 67d7cd398..487f1e68c 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java
    @@ -1,10 +1,13 @@
     package cz.cvut.kbss.termit.util.longrunning;
     
    +import cz.cvut.kbss.termit.event.AllLongRunningTasksCompletedEvent;
     import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent;
     import org.jetbrains.annotations.NotNull;
     import org.jetbrains.annotations.UnmodifiableView;
     import org.slf4j.Logger;
     import org.slf4j.LoggerFactory;
    +import org.springframework.beans.factory.annotation.Autowired;
    +import org.springframework.context.ApplicationEventPublisher;
     import org.springframework.context.event.EventListener;
     import org.springframework.stereotype.Component;
     
    @@ -19,6 +22,13 @@ public class LongRunningTasksRegistry {
     
         private final ConcurrentHashMap registry = new ConcurrentHashMap<>();
     
    +    private final ApplicationEventPublisher eventPublisher;
    +
    +    @Autowired
    +    public LongRunningTasksRegistry(ApplicationEventPublisher eventPublisher) {
    +        this.eventPublisher = eventPublisher;
    +    }
    +
         @EventListener
         public void onTaskChanged(LongRunningTaskChangedEvent event) {
             final LongRunningTaskStatus status = event.getStatus();
    @@ -34,6 +44,10 @@ public void onTaskChanged(LongRunningTaskChangedEvent event) {
             } else {
                 registry.put(status.getName(), status);
             }
    +        if (registry.isEmpty()) {
    +            eventPublisher.publishEvent(new AllLongRunningTasksCompletedEvent(this));
    +            LOG.trace("All long running tasks completed");
    +        }
         }
     
         @NotNull
    diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java
    index 97aee6487..32ac76cb4 100644
    --- a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java
    +++ b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java
    @@ -1,5 +1,6 @@
     package cz.cvut.kbss.termit.websocket;
     
    +import cz.cvut.kbss.termit.event.AllLongRunningTasksCompletedEvent;
     import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent;
     import cz.cvut.kbss.termit.security.SecurityConstants;
     import cz.cvut.kbss.termit.service.IdentifierResolver;
    @@ -37,6 +38,11 @@ public void tasksRequest(@NotNull MessageHeaders messageHeaders) {
                     .forEach(status -> sendToSession(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, status, Map.of(), messageHeaders));
         }
     
    +    @EventListener(AllLongRunningTasksCompletedEvent.class)
    +    public void onAllTasksCompleted() {
    +        messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, "", Map.of("reset", true));
    +    }
    +
         @EventListener
         public void onTaskChanged(LongRunningTaskChangedEvent event) {
             messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, event.getStatus());
    
    From d428f1d6e58e6ea23013dbaf362bc9b4146d0084 Mon Sep 17 00:00:00 2001
    From: lukaskabc 
    Date: Wed, 11 Sep 2024 12:39:45 +0200
    Subject: [PATCH 120/150] [Performance #285] add description for name in
     Throttle annotation
    
    ---
     .../java/cz/cvut/kbss/termit/util/throttle/Throttle.java    | 6 ++++++
     1 file changed, 6 insertions(+)
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
    index f76b1ca64..bce3152a7 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
    @@ -97,5 +97,11 @@
          */
         @NotNull String group() default "";
     
    +    /**
    +     * @return a key name of the task which is displayed on the frontend.
    +     * Example: {@code name = "validation"} on frontend a translatable name with a key
    +     * {@code "longrunningtasks.name.validation"} is displayed.
    +     * Leave blank to hide the task on the frontend.
    +     */
         @Nullable String name() default "";
     }
    
    From cb7c920b124938dca429c2f9bda09ee404458caf Mon Sep 17 00:00:00 2001
    From: lukaskabc 
    Date: Wed, 11 Sep 2024 14:29:34 +0200
    Subject: [PATCH 121/150] [Performance #285] remove entity re-fetch
    
    ---
     .../cz/cvut/kbss/termit/service/business/VocabularyService.java | 1 -
     src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java   | 2 --
     2 files changed, 3 deletions(-)
    
    diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java
    index 9b3111637..09c02ea4b 100644
    --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java
    +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java
    @@ -309,7 +309,6 @@ public List getChangesOfContent(Vocabulary vocabulary) {
                   name = "allTermsVocabularyAnalysis")
         @PreAuthorize("@vocabularyAuthorizationService.canModify(#vocabulary)")
         public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) {
    -        vocabulary = findRequired(vocabulary.getUri()); // required when throttling
             LOG.debug("Analyzing definitions of all terms in vocabulary {} and vocabularies it imports.", vocabulary);
             SnapshotProvider.verifySnapshotNotModified(vocabulary);
             final List allTerms = termService.findAll(vocabulary);
    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
    index bce3152a7..75c08e519 100644
    --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
    +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
    @@ -21,8 +21,6 @@
      * 

    * *

    - * Method can't use any parameters that are part of persistent context as the method will be executed on separated thread, - * objects need to be re-requested.
    * Call to this method cannot be part of an existing transaction. * If {@link org.springframework.transaction.annotation.Transactional @Transactional} is present with this annotation, * new transaction is created for the task execution. From e77b94406a471bcd95a794f7f1af82e33a077d4e Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 11 Sep 2024 14:46:18 +0200 Subject: [PATCH 122/150] [Performance #285] notify about long-running task change on future cancellation --- .../java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index b25220f22..d9c9d2e19 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -504,6 +504,7 @@ private void cancelWithHigherGroup(Identifier throttleAnnotation) { // cancels future if it's not null (should not be) and removes it from map if it was canceled if (throttledFuture != null && throttledFuture.cancel(false)) { throttledFutures.remove(higherKey); + notifyTaskChanged(throttledFuture); } scheduledFutures.remove(higherKey); From 929f8f3ead76518e9dc311cafe33ce48859e1026 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 11 Sep 2024 14:53:22 +0200 Subject: [PATCH 123/150] [Performance #285] add comment to calling get on throttled future --- .../termit/persistence/validation/ResultCachingValidator.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 613e397dc..a1f6ddb2e 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -104,6 +104,7 @@ private Optional> getCached(@NotNull URI originVoca final Collection results; try { // executes real validation + // get is safe here as long as we are on throttled thread from #validate method results = getValidator().validate(originVocabularyIri, iris).get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); From 0ec7d95ff18f1931f6670553e295a42dd3146f2d Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Wed, 11 Sep 2024 15:21:24 +0200 Subject: [PATCH 124/150] [Performance #285] remove unnecessary stubbing for removed method call --- .../cvut/kbss/termit/service/business/VocabularyServiceTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java index 277c26714..2f5a7676b 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java @@ -123,7 +123,6 @@ void runTextAnalysisOnAllTermsInvokesTextAnalysisOnAllTermsInVocabulary() { when(termService.findAll(vocabulary)).thenReturn(terms); when(contextMapper.getVocabularyContext(vocabulary.getUri())).thenReturn(vocabulary.getUri()); when(repositoryService.getTransitivelyImportedVocabularies(vocabulary)).thenReturn(Collections.emptyList()); - when(repositoryService.findRequired(vocabulary.getUri())).thenReturn(vocabulary); sut.runTextAnalysisOnAllTerms(vocabulary); verify(termService).analyzeTermDefinition(termOne, vocabulary.getUri()); verify(termService).analyzeTermDefinition(termTwo, vocabulary.getUri()); From e8f2da373ed9fcf93fd682e10e5db2a981284160 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 08:21:58 +0200 Subject: [PATCH 125/150] [Performance #285] send long running tasks to client as whole map to prevent desync --- .../cz/cvut/kbss/termit/model/Vocabulary.java | 27 +++++++++++++------ .../termit/service/business/TermService.java | 1 + .../longrunning/LongRunningTasksRegistry.java | 11 +++++--- .../LongRunningTasksWebSocketController.java | 13 +++++---- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java b/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java index 06c8acd58..1e53f1ba1 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java +++ b/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java @@ -18,6 +18,7 @@ package cz.cvut.kbss.termit.model; import com.fasterxml.jackson.annotation.JsonIgnore; +import cz.cvut.kbss.jopa.exception.LazyLoadingException; import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jopa.model.annotations.CascadeType; import cz.cvut.kbss.jopa.model.annotations.FetchType; @@ -236,13 +237,23 @@ public int hashCode() { @Override public String toString() { - return "Vocabulary{" + - getLabel() + - " " + Utils.uriToString(getUri()) + - ", glossary=" + glossary + - (importedVocabularies != null ? - ", importedVocabularies = [" + importedVocabularies.stream().map(Utils::uriToString).collect( - Collectors.joining(", ")) + "]" : "") + - '}'; + final StringBuilder builder = new StringBuilder(); + builder.append("Vocabulary{") + .append(getLabel()) + .append(" ") + .append(Utils.uriToString(getUri())); + try { + builder.append(", glossary=" + glossary); + if (importedVocabularies != null) { + builder.append(", importedVocabularies = [" + + importedVocabularies.stream().map(Utils::uriToString) + .collect(Collectors.joining(", ")) + "]"); + } + + } catch (LazyLoadingException e) { + // persistent context not available + } + + return builder.toString(); } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index 466e59a70..09fe782b4 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -442,6 +442,7 @@ public void remove(@NonNull Term term) { @Throttle(value = "{#vocabularyIri, #term.getUri()}", group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyTerm(#vocabulary.getUri(), #term.getUri())", name="termDefinitionAnalysis") + @Transactional @PreAuthorize("@termAuthorizationService.canModify(#term)") public void analyzeTermDefinition(AbstractTerm term, URI vocabularyIri) { Objects.requireNonNull(term); diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java index 487f1e68c..56438f046 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java @@ -9,10 +9,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.util.Collections; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @Component @@ -20,7 +22,7 @@ public class LongRunningTasksRegistry { private static final Logger LOG = LoggerFactory.getLogger(LongRunningTasksRegistry.class); - private final ConcurrentHashMap registry = new ConcurrentHashMap<>(); + private final ConcurrentHashMap registry = new ConcurrentHashMap<>(); private final ApplicationEventPublisher eventPublisher; @@ -29,6 +31,7 @@ public LongRunningTasksRegistry(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } + @Order(0) // ensures that registry is updated before controllers @EventListener public void onTaskChanged(LongRunningTaskChangedEvent event) { final LongRunningTaskStatus status = event.getStatus(); @@ -40,9 +43,9 @@ public void onTaskChanged(LongRunningTaskChangedEvent event) { } if(status.getState() == LongRunningTaskStatus.State.DONE) { - registry.remove(status.getName()); + registry.remove(status.getUuid()); } else { - registry.put(status.getName(), status); + registry.put(status.getUuid(), status); } if (registry.isEmpty()) { eventPublisher.publishEvent(new AllLongRunningTasksCompletedEvent(this)); @@ -52,7 +55,7 @@ public void onTaskChanged(LongRunningTaskChangedEvent event) { @NotNull @UnmodifiableView - public Map getTasks() { + public Map getTasks() { return Collections.unmodifiableMap(registry); } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java index 32ac76cb4..58b4fb908 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java @@ -33,18 +33,17 @@ protected LongRunningTasksWebSocketController(IdentifierResolver idResolver, Con @SubscribeMapping("/update") public void tasksRequest(@NotNull MessageHeaders messageHeaders) { - sendToSession(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, "", Map.of("reset", true), messageHeaders); - registry.getTasks().values() - .forEach(status -> sendToSession(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, status, Map.of(), messageHeaders)); + sendToSession(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, registry.getTasks(), Map.of(), messageHeaders); } @EventListener(AllLongRunningTasksCompletedEvent.class) public void onAllTasksCompleted() { - messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, "", Map.of("reset", true)); + // sending empty payload + messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, Map.of()); } - @EventListener - public void onTaskChanged(LongRunningTaskChangedEvent event) { - messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, event.getStatus()); + @EventListener(LongRunningTaskChangedEvent.class) + public void onTaskChanged() { + messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, registry.getTasks()); } } From cb61a2fd5415e0a9cde9bc2fbea173d0a82e02a9 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 09:31:46 +0200 Subject: [PATCH 126/150] [Performance #285] remove string builders, refetch entities in throttled context --- .../cz/cvut/kbss/termit/model/Vocabulary.java | 17 +++++++---------- .../termit/service/business/TermService.java | 1 + .../service/business/VocabularyService.java | 1 + .../util/longrunning/LongRunningTaskStatus.java | 10 +--------- .../service/business/TermServiceTest.java | 1 + .../service/business/VocabularyServiceTest.java | 1 + 6 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java b/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java index 1e53f1ba1..2198e44f3 100644 --- a/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java +++ b/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java @@ -237,23 +237,20 @@ public int hashCode() { @Override public String toString() { - final StringBuilder builder = new StringBuilder(); - builder.append("Vocabulary{") - .append(getLabel()) - .append(" ") - .append(Utils.uriToString(getUri())); + String result = "Vocabulary{"+ + getLabel() + " " + + Utils.uriToString(getUri()); try { - builder.append(", glossary=" + glossary); + result += ", glossary=" + glossary; if (importedVocabularies != null) { - builder.append(", importedVocabularies = [" + + result +=", importedVocabularies = [" + importedVocabularies.stream().map(Utils::uriToString) - .collect(Collectors.joining(", ")) + "]"); + .collect(Collectors.joining(", ")) + "]"; } - } catch (LazyLoadingException e) { // persistent context not available } - return builder.toString(); + return result; } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index 09fe782b4..2ca3aba18 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -445,6 +445,7 @@ public void remove(@NonNull Term term) { @Transactional @PreAuthorize("@termAuthorizationService.canModify(#term)") public void analyzeTermDefinition(AbstractTerm term, URI vocabularyIri) { + term = findRequired(term.getUri()); // required when throttling for persistent context Objects.requireNonNull(term); if (term.getDefinition() == null || term.getDefinition().isEmpty()) { return; diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 09c02ea4b..1d20cf5b2 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -309,6 +309,7 @@ public List getChangesOfContent(Vocabulary vocabulary) { name = "allTermsVocabularyAnalysis") @PreAuthorize("@vocabularyAuthorizationService.canModify(#vocabulary)") public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) { + vocabulary = findRequired(vocabulary.getUri()); // required when throttling for persistent context LOG.debug("Analyzing definitions of all terms in vocabulary {} and vocabularies it imports.", vocabulary); SnapshotProvider.verifySnapshotNotModified(vocabulary); final List allTerms = termService.findAll(vocabulary); diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java index 9875cd119..8c1da1163 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java @@ -46,15 +46,7 @@ public State getState() { @Override public String toString() { - StringBuilder builder = new StringBuilder(); - builder.append("{").append(this.state.name()); - if (startedAt != null) { - builder.append(", startedAt=").append(startedAt); - } - builder.append(", "); - builder.append(uuid); - builder.append("}"); - return builder.toString(); + return "{" + state.name() + (startedAt == null ? "" : ", startedAt=" + startedAt) + ", " + uuid + "}"; } public enum State { diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java index 55372e8ae..5ea15780f 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/TermServiceTest.java @@ -312,6 +312,7 @@ void removeRemovesTermViaRepositoryService() { void runTextAnalysisInvokesTextAnalysisOnSpecifiedTerm() { when(vocabularyContextMapper.getVocabularyContext(vocabulary.getUri())).thenReturn(vocabulary.getUri()); final Term toAnalyze = generateTermWithId(); + when(termRepositoryService.findRequired(toAnalyze.getUri())).thenReturn(toAnalyze); sut.analyzeTermDefinition(toAnalyze, vocabulary.getUri()); verify(textAnalysisService).analyzeTermDefinition(toAnalyze, vocabulary.getUri()); } diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java index 2f5a7676b..277c26714 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java @@ -123,6 +123,7 @@ void runTextAnalysisOnAllTermsInvokesTextAnalysisOnAllTermsInVocabulary() { when(termService.findAll(vocabulary)).thenReturn(terms); when(contextMapper.getVocabularyContext(vocabulary.getUri())).thenReturn(vocabulary.getUri()); when(repositoryService.getTransitivelyImportedVocabularies(vocabulary)).thenReturn(Collections.emptyList()); + when(repositoryService.findRequired(vocabulary.getUri())).thenReturn(vocabulary); sut.runTextAnalysisOnAllTerms(vocabulary); verify(termService).analyzeTermDefinition(termOne, vocabulary.getUri()); verify(termService).analyzeTermDefinition(termTwo, vocabulary.getUri()); From a7a23ebdbe20e6ac36fbb7963980874921e0c857 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 11:07:26 +0200 Subject: [PATCH 127/150] [PR #295] rename vocabulary events and replace jetbrains annotations --- .../config/WebSocketMessageBrokerConfig.java | 4 +-- ...ava => FileTextAnalysisFinishedEvent.java} | 6 ++-- .../event/LongRunningTaskChangedEvent.java | 7 ++--- ...mDefinitionTextAnalysisFinishedEvent.java} | 6 ++-- .../event/VocabularyContentModifiedEvent.java | 4 +-- .../termit/event/VocabularyCreatedEvent.java | 4 +-- .../kbss/termit/event/VocabularyEvent.java | 4 +-- .../VocabularyValidationFinishedEvent.java | 19 +++++------- .../event/VocabularyWillBeRemovedEvent.java | 4 +-- .../persistence/dao/TermOccurrenceDao.java | 4 ++- .../termit/persistence/dao/VocabularyDao.java | 8 ++--- .../validation/ResultCachingValidator.java | 20 ++++++------- .../persistence/validation/Validator.java | 7 +++-- .../VocabularyContentValidator.java | 6 ++-- .../service/document/TextAnalysisService.java | 8 ++--- .../cvut/kbss/termit/util/ExceptionUtils.java | 7 ++--- .../java/cz/cvut/kbss/termit/util/Pair.java | 12 ++++---- .../util/longrunning/LongRunningTask.java | 8 ++--- .../longrunning/LongRunningTaskScheduler.java | 4 +-- .../longrunning/LongRunningTaskStatus.java | 16 +++++----- .../longrunning/LongRunningTasksRegistry.java | 6 ++-- .../termit/util/throttle/CacheableFuture.java | 2 +- .../SynchronousTransactionExecutor.java | 4 +-- .../kbss/termit/util/throttle/Throttle.java | 8 ++--- .../termit/util/throttle/ThrottleAspect.java | 29 ++++++++++--------- .../termit/util/throttle/ThrottledFuture.java | 20 ++++++------- .../websocket/BaseWebSocketController.java | 12 ++++---- .../LongRunningTasksWebSocketController.java | 4 +-- .../websocket/VocabularySocketController.java | 18 ++++++------ .../document/TextAnalysisServiceTest.java | 8 ++--- .../termit/util/throttle/MockedThrottle.java | 19 ++++++------ .../util/throttle/ScheduledFutureTask.java | 10 +++---- 32 files changed, 146 insertions(+), 152 deletions(-) rename src/main/java/cz/cvut/kbss/termit/event/{VocabularyFileTextAnalysisFinishedEvent.java => FileTextAnalysisFinishedEvent.java} (63%) rename src/main/java/cz/cvut/kbss/termit/event/{VocabularyTermDefinitionTextAnalysisFinishedEvent.java => TermDefinitionTextAnalysisFinishedEvent.java} (60%) diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java index ab5a2dbbc..3f5cdb08d 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java @@ -4,12 +4,12 @@ import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; -import org.jetbrains.annotations.NotNull; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.lang.NonNull; import org.springframework.messaging.Message; import org.springframework.messaging.converter.MappingJackson2MessageConverter; import org.springframework.messaging.converter.MessageConverter; @@ -86,7 +86,7 @@ public void addArgumentResolvers(List argumentRes * @see Spring security source */ @Override - public void configureClientInboundChannel(@NotNull ChannelRegistration registration) { + public void configureClientInboundChannel(@NonNull ChannelRegistration registration) { AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(messageAuthorizationManager); interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(context)); registration.interceptors(webSocketJwtAuthorizationInterceptor, new SecurityContextChannelInterceptor(), interceptor); diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyFileTextAnalysisFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java similarity index 63% rename from src/main/java/cz/cvut/kbss/termit/event/VocabularyFileTextAnalysisFinishedEvent.java rename to src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java index 3ebf85a96..d8d7caa40 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyFileTextAnalysisFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java @@ -1,18 +1,18 @@ package cz.cvut.kbss.termit.event; import cz.cvut.kbss.termit.model.resource.File; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import java.net.URI; /** * Indicates that text analysis of a file was finished */ -public class VocabularyFileTextAnalysisFinishedEvent extends VocabularyEvent { +public class FileTextAnalysisFinishedEvent extends VocabularyEvent { private final URI fileUri; - public VocabularyFileTextAnalysisFinishedEvent(Object source, @NotNull File file) { + public FileTextAnalysisFinishedEvent(Object source, @NonNull File file) { super(source, file.getDocument().getVocabulary()); this.fileUri = file.getUri(); } diff --git a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java index 2c5fbeb5e..0353e0e02 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java @@ -2,23 +2,22 @@ import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskStatus; -import org.jetbrains.annotations.NotNull; import org.springframework.context.ApplicationEvent; +import org.springframework.lang.NonNull; /** * Indicates a status change of a long-running task. */ public class LongRunningTaskChangedEvent extends ApplicationEvent { - @NotNull private final LongRunningTaskStatus status; - public LongRunningTaskChangedEvent(@NotNull Object source, final @NotNull LongRunningTask longRunningTask) { + public LongRunningTaskChangedEvent(@NonNull Object source, final @NonNull LongRunningTask longRunningTask) { super(source); this.status = new LongRunningTaskStatus(longRunningTask); } - public @NotNull LongRunningTaskStatus getStatus() { + public @NonNull LongRunningTaskStatus getStatus() { return status; } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyTermDefinitionTextAnalysisFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java similarity index 60% rename from src/main/java/cz/cvut/kbss/termit/event/VocabularyTermDefinitionTextAnalysisFinishedEvent.java rename to src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java index 0ebad21a4..748d7a075 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyTermDefinitionTextAnalysisFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java @@ -1,17 +1,17 @@ package cz.cvut.kbss.termit.event; import cz.cvut.kbss.termit.model.AbstractTerm; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import java.net.URI; /** * Indicates that a text analysis of a term definition was finished */ -public class VocabularyTermDefinitionTextAnalysisFinishedEvent extends VocabularyEvent { +public class TermDefinitionTextAnalysisFinishedEvent extends VocabularyEvent { private final URI termUri; - public VocabularyTermDefinitionTextAnalysisFinishedEvent(Object source, @NotNull AbstractTerm term) { + public TermDefinitionTextAnalysisFinishedEvent(@NonNull Object source, @NonNull AbstractTerm term) { super(source, term.getVocabulary()); this.termUri = term.getUri(); } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java index b6c9cfd17..324c1d45c 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java @@ -17,7 +17,7 @@ */ package cz.cvut.kbss.termit.event; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import java.net.URI; @@ -28,7 +28,7 @@ */ public class VocabularyContentModifiedEvent extends VocabularyEvent { - public VocabularyContentModifiedEvent(Object source, @NotNull URI vocabularyIri) { + public VocabularyContentModifiedEvent(@NonNull Object source, @NonNull URI vocabularyIri) { super(source, vocabularyIri); } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java index 6e3aceb5a..704169105 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java @@ -17,7 +17,7 @@ */ package cz.cvut.kbss.termit.event; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import java.net.URI; @@ -26,7 +26,7 @@ */ public class VocabularyCreatedEvent extends VocabularyEvent { - public VocabularyCreatedEvent(Object source, @NotNull URI vocabularyIri) { + public VocabularyCreatedEvent(@NonNull Object source, @NonNull URI vocabularyIri) { super(source, vocabularyIri); } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java index 4314cd6e3..133afe2f5 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.event; -import org.jetbrains.annotations.NotNull; import org.springframework.context.ApplicationEvent; +import org.springframework.lang.NonNull; import java.net.URI; import java.util.Objects; @@ -12,7 +12,7 @@ public abstract class VocabularyEvent extends ApplicationEvent { protected final URI vocabularyIri; - protected VocabularyEvent(Object source, @NotNull URI vocabularyIri) { + protected VocabularyEvent(@NonNull Object source, @NonNull URI vocabularyIri) { super(source); Objects.requireNonNull(vocabularyIri); this.vocabularyIri = vocabularyIri; diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java index 07f0a29ac..724cc13a9 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java @@ -1,8 +1,7 @@ package cz.cvut.kbss.termit.event; import cz.cvut.kbss.termit.model.validation.ValidationResult; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Unmodifiable; +import org.springframework.lang.NonNull; import java.net.URI; import java.util.Collection; @@ -18,12 +17,8 @@ public class VocabularyValidationFinishedEvent extends VocabularyEvent { * Vocabulary closure of {@link #vocabularyIri}. * IRIs of vocabularies that are imported by {@link #vocabularyIri} and were part of the validation. */ - @NotNull - @Unmodifiable private final Collection vocabularyIris; - @NotNull - @Unmodifiable private final Collection validationResults; /** @@ -32,19 +27,21 @@ public class VocabularyValidationFinishedEvent extends VocabularyEvent { * @param vocabularyIris IRI of the vocabulary on which the validation was triggered. * @param validationResults results of the validation */ - public VocabularyValidationFinishedEvent(@NotNull Object source, @NotNull URI originVocabularyIri, - @NotNull Collection vocabularyIris, - @NotNull List validationResults) { + public VocabularyValidationFinishedEvent(@NonNull Object source, @NonNull URI originVocabularyIri, + @NonNull Collection vocabularyIris, + @NonNull List validationResults) { super(source, originVocabularyIri); this.vocabularyIris = Collections.unmodifiableCollection(vocabularyIris); this.validationResults = Collections.unmodifiableCollection(validationResults); } - public @NotNull Collection getVocabularyIris() { + @NonNull + public Collection getVocabularyIris() { return vocabularyIris; } - public @NotNull Collection getValidationResults() { + @NonNull + public Collection getValidationResults() { return validationResults; } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java index 96916d450..0e0b6503a 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.event; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import java.net.URI; @@ -9,7 +9,7 @@ */ public class VocabularyWillBeRemovedEvent extends VocabularyEvent { - public VocabularyWillBeRemovedEvent(Object source, @NotNull URI vocabularyIri) { + public VocabularyWillBeRemovedEvent(@NonNull Object source, @NonNull URI vocabularyIri) { super(source, vocabularyIri); } } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java index e623ff3b3..1b01a46d3 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java @@ -31,6 +31,7 @@ import cz.cvut.kbss.termit.model.assignment.TermOccurrence; import cz.cvut.kbss.termit.persistence.dao.util.SparqlResultToTermOccurrenceMapper; import cz.cvut.kbss.termit.util.Configuration; +import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.Vocabulary; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -258,7 +259,8 @@ public void removeAll(Asset target) { em.createNativeQuery("DROP GRAPH ?context") .setParameter("context", sourceContext) .executeUpdate(); - LOG.debug("Removed all occurrences from {}", sourceContext); + LOG.atDebug().setMessage("Removed all occurrences from {}") + .addArgument(() -> Utils.uriToString(sourceContext)).log(); } /** diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java index bf61cf07e..21e5233f4 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java @@ -145,17 +145,17 @@ public Vocabulary getReference(URI id) { /** * Gets identifiers of all vocabularies imported by the specified vocabulary, including transitively imported ones. * - * @param vocabulary Base vocabulary, whose imports should be retrieved + * @param vocabularyIri Identifier of base vocabulary, whose imports should be retrieved * @return Collection of (transitively) imported vocabularies */ - public Collection getTransitivelyImportedVocabularies(URI vocabulary) { - Objects.requireNonNull(vocabulary); + public Collection getTransitivelyImportedVocabularies(URI vocabularyIri) { + Objects.requireNonNull(vocabularyIri); try { return em.createNativeQuery("SELECT DISTINCT ?imported WHERE {" + "?x ?imports+ ?imported ." + "}", URI.class) .setParameter("imports", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_p_importuje_slovnik)) - .setParameter("x", vocabulary).getResultList(); + .setParameter("x", vocabularyIri).getResultList(); } catch (RuntimeException e) { throw new PersistenceException(e); } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index a1f6ddb2e..285f96898 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -25,14 +25,13 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.util.throttle.Throttle; import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Lookup; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; import org.springframework.context.event.EventListener; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -58,18 +57,18 @@ public class ResultCachingValidator implements VocabularyContentValidator { * Map of origin vocabulary IRI to vocabulary iri closure of imported vocabularies. * When the record is missing, the cache is considered as dirty. */ - private final Map> vocabularyClosure = new ConcurrentHashMap<>(); + private final Map> vocabularyClosure = new ConcurrentHashMap<>(); - private final Map> validationCache = new HashMap<>(); + private final Map> validationCache = new HashMap<>(); /** * @return true when the cache contents are dirty and should be refreshed; false otherwise. */ - public boolean isNotDirty(@NotNull URI originVocabularyIri) { + public boolean isNotDirty(@NonNull URI originVocabularyIri) { return vocabularyClosure.containsKey(originVocabularyIri); } - private Optional> getCached(@NotNull URI originVocabularyIri) { + private Optional> getCached(@NonNull URI originVocabularyIri) { synchronized (validationCache) { return Optional.ofNullable(validationCache.get(originVocabularyIri)); } @@ -78,7 +77,8 @@ private Optional> getCached(@NotNull URI originVoca @Throttle(value = "{#originVocabularyIri}", name="vocabularyValidation") @Transactional @Override - public @NotNull ThrottledFuture> validate(@NotNull URI originVocabularyIri, @NotNull Collection vocabularyIris) { + @NonNull + public ThrottledFuture> validate(@NonNull URI originVocabularyIri, @NonNull Collection vocabularyIris) { final Set iris = Set.copyOf(vocabularyIris); if (iris.isEmpty()) { @@ -94,8 +94,8 @@ private Optional> getCached(@NotNull URI originVoca return ThrottledFuture.of(() -> runValidation(originVocabularyIri, iris)).setCachedResult(cached.orElse(null)); } - - private @NotNull Collection runValidation(@NotNull URI originVocabularyIri, @NotNull final Set iris) { + @NonNull + private Collection runValidation(@NonNull URI originVocabularyIri, @NonNull final Set iris) { Optional> cached = getCached(originVocabularyIri); if (isNotDirty(originVocabularyIri) && cached.isPresent()) { return cached.get(); @@ -134,7 +134,7 @@ public void evictVocabularyCache(VocabularyEvent event) { // now mark all vocabularies importing modified vocabulary as dirty too synchronized (validationCache) { vocabularyClosure.keySet().forEach(originVocabularyIri -> { - final @Nullable Collection closure = vocabularyClosure.get(originVocabularyIri); + final Collection closure = vocabularyClosure.get(originVocabularyIri); if (closure != null && closure.contains(event.getVocabularyIri())) { vocabularyClosure.remove(originVocabularyIri); } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java index 40c72dbb9..b01ac7dbf 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java @@ -39,13 +39,13 @@ import org.eclipse.rdf4j.repository.Repository; import org.eclipse.rdf4j.repository.RepositoryConnection; import org.eclipse.rdf4j.rio.turtle.TurtleWriter; -import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -146,7 +146,8 @@ private void loadOverrideRules(Model validationModel, String language) throws IO @Throttle(value = "{#originVocabularyIri}", name = "vocabularyValidation") @Transactional(readOnly = true) @Override - public @NotNull ThrottledFuture> validate(final @NotNull URI originVocabularyIri, final @NotNull Collection vocabularyIris) { + @NonNull + public ThrottledFuture> validate(final @NonNull URI originVocabularyIri, final @NonNull Collection vocabularyIris) { if (vocabularyIris.isEmpty()) { return ThrottledFuture.done(List.of()); } @@ -158,7 +159,7 @@ private void loadOverrideRules(Model validationModel, String language) throws IO }); } - protected synchronized List runValidation(@NotNull Collection vocabularyIris) { + protected synchronized List runValidation(@NonNull Collection vocabularyIris) { LOG.debug("Validating {}", vocabularyIris); try { LOG.trace("Constructing model from RDF4J repository..."); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java index 3d6290b7c..54ffa94ae 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java @@ -19,7 +19,7 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import java.net.URI; import java.util.Collection; @@ -38,6 +38,6 @@ public interface VocabularyContentValidator { * @param vocabularyIris Vocabulary identifiers (including {@code originVocabularyIri} * @return List of violations of validation rules. Empty list if there are not violations */ - @NotNull - ThrottledFuture> validate(@NotNull URI originVocabularyIri, @NotNull Collection vocabularyIris); + @NonNull + ThrottledFuture> validate(@NonNull URI originVocabularyIri, @NonNull Collection vocabularyIris); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java index be8e8c228..adc9dfdae 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java @@ -18,8 +18,8 @@ package cz.cvut.kbss.termit.service.document; import cz.cvut.kbss.termit.dto.TextAnalysisInput; -import cz.cvut.kbss.termit.event.VocabularyFileTextAnalysisFinishedEvent; -import cz.cvut.kbss.termit.event.VocabularyTermDefinitionTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.FileTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.TermDefinitionTextAnalysisFinishedEvent; import cz.cvut.kbss.termit.exception.WebServiceIntegrationException; import cz.cvut.kbss.termit.model.AbstractTerm; import cz.cvut.kbss.termit.model.TextAnalysisRecord; @@ -96,7 +96,7 @@ public void analyzeFile(File file, Set vocabularyContexts) { input.setVocabularyContexts(vocabularyContexts); invokeTextAnalysisOnFile(file, input); LOG.debug("Text analysis finished for resource {}.", file.getUri()); - eventPublisher.publishEvent(new VocabularyFileTextAnalysisFinishedEvent(this, file)); + eventPublisher.publishEvent(new FileTextAnalysisFinishedEvent(this, file)); } private TextAnalysisInput createAnalysisInput(File file) { @@ -190,7 +190,7 @@ public void analyzeTermDefinition(AbstractTerm term, URI vocabularyContext) { input.setVocabularyRepositoryPassword(config.getRepository().getPassword()); invokeTextAnalysisOnTerm(term, input); - eventPublisher.publishEvent(new VocabularyTermDefinitionTextAnalysisFinishedEvent(this, term)); + eventPublisher.publishEvent(new TermDefinitionTextAnalysisFinishedEvent(this, term)); } } diff --git a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java index ad9ada966..e31b081c7 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java @@ -1,7 +1,6 @@ package cz.cvut.kbss.termit.util; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import org.springframework.lang.NonNull; import java.util.HashSet; import java.util.Set; @@ -14,8 +13,8 @@ private ExceptionUtils() { /** * Resolves all nested causes of the {@code throwable} and returns true if any is matching the {@code cause} */ - public static boolean isCausedBy(@Nullable final Throwable throwable, @NotNull final Class cause) { - @Nullable Throwable t = throwable; + public static boolean isCausedBy(final Throwable throwable, @NonNull final Class cause) { + Throwable t = throwable; final Set visited = new HashSet<>(); while (t != null) { if(visited.add(t)) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/Pair.java b/src/main/java/cz/cvut/kbss/termit/util/Pair.java index ff23400a0..ad0f36a34 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Pair.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Pair.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.util; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import java.util.Objects; @@ -28,15 +28,15 @@ public V getSecond() { /** * First compares the first value, if they are equal, compares the second value. */ - public static class Comparable, V extends java.lang.Comparable> - extends Pair implements java.lang.Comparable> { + public static class ComparablePair, V extends java.lang.Comparable> + extends Pair implements java.lang.Comparable> { - public Comparable(T first, V second) { + public ComparablePair(T first, V second) { super(first, second); } @Override - public int compareTo(@NotNull Comparable o) { + public int compareTo(@NonNull Pair.ComparablePair o) { final int firstComparison = this.getFirst().compareTo(o.getFirst()); if (firstComparison != 0) { return firstComparison; @@ -48,7 +48,7 @@ public int compareTo(@NotNull Comparable o) { public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - Comparable that = (Comparable) o; + ComparablePair that = (ComparablePair) o; return Objects.equals(getFirst(), that.getFirst()) && Objects.equals(getSecond(), that.getSecond()); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java index 025cd51b8..d59913ec2 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.util.longrunning; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import java.time.Instant; import java.util.Optional; @@ -35,9 +35,9 @@ public interface LongRunningTask { * @return a timestamp of the task execution start, * or empty if the task execution has not yet started. */ - @NotNull + @NonNull Optional startedAt(); - @NotNull + @NonNull UUID getUuid(); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java index 1570d3b80..be693e5f1 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java @@ -1,8 +1,8 @@ package cz.cvut.kbss.termit.util.longrunning; import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent; -import org.jetbrains.annotations.NotNull; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.lang.NonNull; /** * An object that will schedule a long-running tasks @@ -15,7 +15,7 @@ protected LongRunningTaskScheduler(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } - protected final void notifyTaskChanged(final @NotNull LongRunningTask task) { + protected final void notifyTaskChanged(final @NonNull LongRunningTask task) { if (task.getName() != null && !task.getName().isBlank()) { eventPublisher.publishEvent(new LongRunningTaskChangedEvent(this, task)); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java index 8c1da1163..0a1fdaa70 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.util.longrunning; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import java.io.Serializable; import java.time.Instant; @@ -10,17 +10,15 @@ public class LongRunningTaskStatus implements Serializable { - @NotNull private final String name; - @NotNull private final UUID uuid; private final State state; - private final @Nullable Instant startedAt; + private final Instant startedAt; - public LongRunningTaskStatus(@NotNull LongRunningTask task) { + public LongRunningTaskStatus(@NonNull LongRunningTask task) { Objects.requireNonNull(task.getName()); this.name = task.getName(); this.startedAt = task.startedAt().orElse(null); @@ -28,7 +26,7 @@ public LongRunningTaskStatus(@NotNull LongRunningTask task) { this.uuid = task.getUuid(); } - public @NotNull String getName() { + public @NonNull String getName() { return name; } @@ -40,7 +38,7 @@ public State getState() { return startedAt; } - public @NotNull UUID getUuid() { + public @NonNull UUID getUuid() { return uuid; } @@ -52,7 +50,7 @@ public String toString() { public enum State { PENDING, RUNNING, DONE; - public static State of(@NotNull LongRunningTask task) { + public static State of(@NonNull LongRunningTask task) { if (task.isRunning()) { return RUNNING; } else if (task.isDone()) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java index 56438f046..20d196ff4 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java @@ -2,14 +2,13 @@ import cz.cvut.kbss.termit.event.AllLongRunningTasksCompletedEvent; import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.UnmodifiableView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import java.util.Collections; @@ -53,8 +52,7 @@ public void onTaskChanged(LongRunningTaskChangedEvent event) { } } - @NotNull - @UnmodifiableView + @NonNull public Map getTasks() { return Collections.unmodifiableMap(registry); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java index 74b2ff558..6af5651d5 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.util.throttle; import cz.cvut.kbss.termit.exception.TermItException; -import org.jetbrains.annotations.Nullable; +import org.springframework.lang.Nullable; import java.util.Optional; import java.util.concurrent.ExecutionException; diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java index b6b35dabf..74b31b905 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -16,7 +16,7 @@ public class SynchronousTransactionExecutor implements Executor { @Transactional @Override - public void execute(@NotNull Runnable command) { + public void execute(@NonNull Runnable command) { command.run(); } } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java index 75c08e519..cc9c9080b 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java @@ -1,8 +1,8 @@ package cz.cvut.kbss.termit.util.throttle; import cz.cvut.kbss.termit.util.Constants; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -69,7 +69,7 @@ * returning a List of Objects or a String which will be used to construct the unique identifier * for this throttled instance. */ - @NotNull String value() default ""; + @NonNull String value() default ""; /** * The Spring-EL expression @@ -93,7 +93,7 @@ * Blank string disables any group processing. * @see String#compareTo(String) */ - @NotNull String group() default ""; + @NonNull String group() default ""; /** * @return a key name of the task which is displayed on the frontend. diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index d9c9d2e19..4cc555b84 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -9,8 +9,6 @@ import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -27,6 +25,8 @@ import org.springframework.expression.spel.support.DataBindingPropertyAccessor; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.expression.spel.support.StandardTypeLocator; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import org.springframework.scheduling.TaskScheduler; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -126,9 +126,10 @@ public class ThrottleAspect extends LongRunningTaskScheduler { /** * A timestamp of the last time maps were cleaned. + * The reference might be null. * @see #clearOldFutures() */ - private final @NotNull AtomicReference<@NotNull Instant> lastClear; + private final AtomicReference lastClear; @Autowired public ThrottleAspect(@Qualifier("longRunningTaskScheduler") TaskScheduler taskScheduler, @@ -185,8 +186,8 @@ private static StandardEvaluationContext makeDefaultContext() { * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or {@link Future} * @implNote Around advice configured in {@code spring-aop.xml} */ - public @Nullable Object throttleMethodCall(@NotNull ProceedingJoinPoint joinPoint, - @NotNull Throttle throttleAnnotation) throws Throwable { + public @Nullable Object throttleMethodCall(@NonNull ProceedingJoinPoint joinPoint, + @NonNull Throttle throttleAnnotation) throws Throwable { // if the current thread is already executing a throttled code, we want to skip further throttling if (throttledThreads.contains(Thread.currentThread().getId())) { @@ -203,8 +204,8 @@ private static StandardEvaluationContext makeDefaultContext() { return doThrottle(joinPoint, throttleAnnotation); } - private synchronized @Nullable Object doThrottle(@NotNull ProceedingJoinPoint joinPoint, - @NotNull Throttle throttleAnnotation) throws Throwable { + private synchronized @Nullable Object doThrottle(@NonNull ProceedingJoinPoint joinPoint, + @NonNull Throttle throttleAnnotation) throws Throwable { final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); @@ -302,9 +303,9 @@ private EvaluationContext makeContext(JoinPoint joinPoint, Map p return context; } - private Pair> getFutureTask(@NotNull ProceedingJoinPoint joinPoint, - @NotNull Identifier identifier, - @NotNull ThrottledFuture future) + private Pair> getFutureTask(@NonNull ProceedingJoinPoint joinPoint, + @NonNull Identifier identifier, + @NonNull ThrottledFuture future) throws Throwable { final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); @@ -522,7 +523,7 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati return new Identifier(groupIdentifier, joinPoint.getSignature().toShortString() + "-" + identifier); } - private @Nullable Object resultVoidOrFuture(@NotNull MethodSignature signature, ThrottledFuture future) + private @Nullable Object resultVoidOrFuture(@NonNull MethodSignature signature, ThrottledFuture future) throws IllegalCallerException { Class returnType = signature.getReturnType(); if (returnType.isAssignableFrom(ThrottledFuture.class)) { @@ -536,7 +537,7 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati @SuppressWarnings({"unchecked"}) - private @NotNull String constructIdentifier(JoinPoint joinPoint, String expression) throws ThrottleAspectException { + private @NonNull String constructIdentifier(JoinPoint joinPoint, String expression) throws ThrottleAspectException { if (expression == null || expression.isBlank()) { return ""; } @@ -572,7 +573,7 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati * * Implements comparable, first comparing group, then identifier. */ - protected static class Identifier extends Pair.Comparable { + protected static class Identifier extends Pair.ComparablePair { public Identifier(String group, String identifier) { super(group, identifier); @@ -586,7 +587,7 @@ public String getIdentifier() { return this.getSecond(); } - public boolean hasGroupPrefix(@NotNull String group) { + public boolean hasGroupPrefix(@NonNull String group) { return this.getGroup().indexOf(group) == 0 && !this.getGroup().isBlank() && !group.isBlank(); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index 1eb73f227..a20620398 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -3,8 +3,8 @@ import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import java.time.Instant; import java.util.ArrayList; @@ -35,11 +35,11 @@ public class ThrottledFuture implements CacheableFuture, LongRunningTask { private final List> onCompletion = new ArrayList<>(); - private final AtomicReference<@Nullable Instant> startedAt = new AtomicReference<>(null); + private final AtomicReference startedAt = new AtomicReference<>(null); private @Nullable String name = null; - private ThrottledFuture(@NotNull final Supplier task) { + private ThrottledFuture(@NonNull final Supplier task) { this.task = task; future = new CompletableFuture<>(); } @@ -48,11 +48,11 @@ protected ThrottledFuture() { future = new CompletableFuture<>(); } - public static ThrottledFuture of(@NotNull final Supplier supplier) { + public static ThrottledFuture of(@NonNull final Supplier supplier) { return new ThrottledFuture<>(supplier); } - public static ThrottledFuture of(@NotNull final Runnable runnable) { + public static ThrottledFuture of(@NonNull final Runnable runnable) { return new ThrottledFuture<>(() -> { runnable.run(); return null; @@ -115,7 +115,7 @@ public T get() throws InterruptedException, ExecutionException { * Does not execute the task, blocks the current thread until the result is available. */ @Override - public T get(long timeout, @NotNull TimeUnit unit) + public T get(long timeout, @NonNull TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { return future.get(timeout, unit); } @@ -124,7 +124,7 @@ public T get(long timeout, @NotNull TimeUnit unit) * @return If the current task is already running, was canceled or already completed, returns a new future for the given task. * Otherwise, replaces the current task and returns self. */ - protected ThrottledFuture update(Supplier task, @NotNull List> onCompletion) { + protected ThrottledFuture update(Supplier task, @NonNull List> onCompletion) { boolean locked = false; try { locked = lock.tryLock(); @@ -226,12 +226,12 @@ public boolean isRunning() { } @Override - public @NotNull Optional startedAt() { + public @NonNull Optional startedAt() { return Optional.ofNullable(startedAt.get()); } @Override - public @NotNull UUID getUuid() { + public @NonNull UUID getUuid() { return uuid; } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java index 8f41db137..55f152033 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java @@ -3,7 +3,7 @@ import cz.cvut.kbss.termit.rest.BaseController; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -38,8 +38,8 @@ protected BaseWebSocketController(IdentifierResolver idResolver, Configuration c * @param replyHeaders native headers for the reply * @param sourceHeaders original headers containing session id or name of the user */ - protected void sendToSession(@NotNull String destination, @NotNull Object payload, - @NotNull Map replyHeaders, @NotNull MessageHeaders sourceHeaders) { + protected void sendToSession(@NonNull String destination, @NonNull Object payload, + @NonNull Map replyHeaders, @NonNull MessageHeaders sourceHeaders) { getSessionId(sourceHeaders) .ifPresentOrElse(sessionId -> { // session id present StompHeaderAccessor headerAccessor = StompHeaderAccessor.create(StompCommand.MESSAGE); @@ -60,11 +60,11 @@ protected void sendToSession(@NotNull String destination, @NotNull Object payloa * * @return name or session id, or empty when information is not available. */ - protected @NotNull Optional getUser(@NotNull MessageHeaders messageHeaders) { + protected @NonNull Optional getUser(@NonNull MessageHeaders messageHeaders) { return getUserName(messageHeaders).or(() -> getSessionId(messageHeaders)); } - private @NotNull Optional getSessionId(@NotNull MessageHeaders messageHeaders) { + private @NonNull Optional getSessionId(@NonNull MessageHeaders messageHeaders) { return Optional.ofNullable(SimpMessageHeaderAccessor.getSessionId(messageHeaders)); } @@ -73,7 +73,7 @@ protected void sendToSession(@NotNull String destination, @NotNull Object payloa * * @return the name or null */ - private @NotNull Optional getUserName(MessageHeaders headers) { + private @NonNull Optional getUserName(MessageHeaders headers) { Principal principal = SimpMessageHeaderAccessor.getUser(headers); if (principal != null) { final String name = (principal instanceof DestinationUserNameProvider provider ? diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java index 58b4fb908..5a0e0215b 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java @@ -6,8 +6,8 @@ import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry; -import org.jetbrains.annotations.NotNull; import org.springframework.context.event.EventListener; +import org.springframework.lang.NonNull; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -32,7 +32,7 @@ protected LongRunningTasksWebSocketController(IdentifierResolver idResolver, Con } @SubscribeMapping("/update") - public void tasksRequest(@NotNull MessageHeaders messageHeaders) { + public void tasksRequest(@NonNull MessageHeaders messageHeaders) { sendToSession(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, registry.getTasks(), Map.of(), messageHeaders); } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index ee62d2fc4..0f17f8c21 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -1,8 +1,8 @@ package cz.cvut.kbss.termit.websocket; import cz.cvut.kbss.termit.event.VocabularyEvent; -import cz.cvut.kbss.termit.event.VocabularyFileTextAnalysisFinishedEvent; -import cz.cvut.kbss.termit.event.VocabularyTermDefinitionTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.FileTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.TermDefinitionTextAnalysisFinishedEvent; import cz.cvut.kbss.termit.event.VocabularyValidationFinishedEvent; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.validation.ValidationResult; @@ -12,8 +12,8 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.throttle.CacheableFuture; -import org.jetbrains.annotations.NotNull; import org.springframework.context.event.EventListener; +import org.springframework.lang.NonNull; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.Header; @@ -49,7 +49,7 @@ protected VocabularySocketController(IdentifierResolver idResolver, Configuratio public void validateVocabulary(@DestinationVariable String localName, @Header(name = Constants.QueryParams.NAMESPACE, required = false) Optional namespace, - @NotNull MessageHeaders messageHeaders) { + @NonNull MessageHeaders messageHeaders) { final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); final Vocabulary vocabulary = vocabularyService.getReference(identifier); @@ -91,7 +91,7 @@ public void onVocabularyValidationFinished(VocabularyValidationFinishedEvent eve } @EventListener - public void onFileTextAnalysisFinished(VocabularyFileTextAnalysisFinishedEvent event) { + public void onFileTextAnalysisFinished(FileTextAnalysisFinishedEvent event) { messagingTemplate.convertAndSend( WebSocketDestinations.VOCABULARIES_TEXT_ANALYSIS_FINISHED_FILE, event.getFileUri(), @@ -100,7 +100,7 @@ public void onFileTextAnalysisFinished(VocabularyFileTextAnalysisFinishedEvent e } @EventListener - public void onTermDefinitionTextAnalysisFinished(VocabularyTermDefinitionTextAnalysisFinishedEvent event) { + public void onTermDefinitionTextAnalysisFinished(TermDefinitionTextAnalysisFinishedEvent event) { messagingTemplate.convertAndSend( WebSocketDestinations.VOCABULARIES_TEXT_ANALYSIS_FINISHED_TERM_DEFINITION, event.getTermUri(), @@ -108,15 +108,15 @@ public void onTermDefinitionTextAnalysisFinished(VocabularyTermDefinitionTextAna ); } - protected @NotNull Map getHeaders(@NotNull VocabularyEvent event) { + protected @NonNull Map getHeaders(@NonNull VocabularyEvent event) { return getHeaders(event.getVocabularyIri()); } - protected @NotNull Map getHeaders(@NotNull URI vocabularyUri) { + protected @NonNull Map getHeaders(@NonNull URI vocabularyUri) { return getHeaders(vocabularyUri, Map.of()); } - protected @NotNull Map getHeaders(@NotNull URI vocabularyUri, Map headers) { + protected @NonNull Map getHeaders(@NonNull URI vocabularyUri, Map headers) { final Map headersMap = new HashMap<>(headers); headersMap.put("vocabulary", vocabularyUri); return headersMap; diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java index 5b4df0ad8..aa431671e 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/TextAnalysisServiceTest.java @@ -24,8 +24,8 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.environment.PropertyMockingApplicationContextInitializer; -import cz.cvut.kbss.termit.event.VocabularyFileTextAnalysisFinishedEvent; -import cz.cvut.kbss.termit.event.VocabularyTermDefinitionTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.FileTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.TermDefinitionTextAnalysisFinishedEvent; import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.exception.WebServiceIntegrationException; import cz.cvut.kbss.termit.model.Term; @@ -424,7 +424,7 @@ void analyzeFilePublishesAnalysisFinishedEvent() { .andRespond(withSuccess(CONTENT, MediaType.APPLICATION_XML)); sut.analyzeFile(file, Collections.singleton(vocabulary.getUri())); - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(VocabularyFileTextAnalysisFinishedEvent.class); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(FileTextAnalysisFinishedEvent.class); verify(eventPublisher).publishEvent(eventCaptor.capture()); assertNotNull(eventCaptor.getValue()); assertEquals(file.getUri(), eventCaptor.getValue().getFileUri()); @@ -444,7 +444,7 @@ void analyzeTermDefinitionPublishesAnalysisFinishedEvent() throws JsonProcessing sut.analyzeTermDefinition(term, vocabulary.getUri()); - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(VocabularyTermDefinitionTextAnalysisFinishedEvent.class); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(TermDefinitionTextAnalysisFinishedEvent.class); verify(eventPublisher).publishEvent(eventCaptor.capture()); assertNotNull(eventCaptor.getValue()); assertEquals(term.getUri(), eventCaptor.getValue().getTermUri()); diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java index 69f8430b3..398b435ca 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java @@ -1,7 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import org.springframework.lang.NonNull; import java.lang.annotation.Annotation; @@ -10,27 +9,27 @@ */ public class MockedThrottle implements Throttle { - private @NotNull String value; + private String value; - private @NotNull String group; + private String group; - public MockedThrottle(@NotNull String value, @NotNull String group) { + public MockedThrottle(@NonNull String value, @NonNull String group) { this.value = value; this.group = group; } @Override - public @NotNull String value() { + public @NonNull String value() { return value; } @Override - public @NotNull String group() { + public @NonNull String group() { return group; } @Override - public @Nullable String name() { + public String name() { return "NameOfMockedThrottle"+group+value; } @@ -39,11 +38,11 @@ public Class annotationType() { return Throttle.class; } - public void setValue(@NotNull String value) { + public void setValue(@NonNull String value) { this.value = value; } - public void setGroup(@NotNull String group) { + public void setGroup(@NonNull String group) { this.group = group; } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java index ad1148079..f48c43dbd 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; -import org.jetbrains.annotations.NotNull; +import org.springframework.lang.NonNull; import java.util.concurrent.Callable; import java.util.concurrent.Delayed; @@ -10,21 +10,21 @@ public class ScheduledFutureTask extends FutureTask implements ScheduledFuture { - public ScheduledFutureTask(@NotNull Callable callable) { + public ScheduledFutureTask(@NonNull Callable callable) { super(callable); } - public ScheduledFutureTask(@NotNull Runnable runnable, T result) { + public ScheduledFutureTask(@NonNull Runnable runnable, T result) { super(runnable, result); } @Override - public long getDelay(@NotNull TimeUnit unit) { + public long getDelay(@NonNull TimeUnit unit) { throw new UnsupportedOperationException("Not implemented"); } @Override - public int compareTo(@NotNull Delayed o) { + public int compareTo(@NonNull Delayed o) { throw new UnsupportedOperationException("Not implemented"); } } From d4e2676810fa8b0fdecd304ac79d4f09b0dda1ba Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 11:33:03 +0200 Subject: [PATCH 128/150] [Performance #285] fix event ordering in long-running tasks registry --- .../longrunning/LongRunningTasksRegistry.java | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java index 20d196ff4..5f899bc03 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java @@ -15,6 +15,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; @Component public class LongRunningTasksRegistry { @@ -25,6 +26,8 @@ public class LongRunningTasksRegistry { private final ApplicationEventPublisher eventPublisher; + private final ReentrantLock lock = new ReentrantLock(true); // using fair ordering + @Autowired public LongRunningTasksRegistry(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; @@ -32,13 +35,21 @@ public LongRunningTasksRegistry(ApplicationEventPublisher eventPublisher) { @Order(0) // ensures that registry is updated before controllers @EventListener - public void onTaskChanged(LongRunningTaskChangedEvent event) { + public void onTaskChanged(final LongRunningTaskChangedEvent event) { + // using fair ReentrantLock guarantees receiving events in the order + lock.lock(); + try { + handleTaskChanged(event); + } finally { + lock.unlock(); + } + } + + private void handleTaskChanged(final LongRunningTaskChangedEvent event) { final LongRunningTaskStatus status = event.getStatus(); if (LOG.isTraceEnabled()) { - synchronized (LongRunningTasksRegistry.class) { - LOG.atTrace().setMessage("Long running task changed state: {}{}").addArgument(status::getName) - .addArgument(status).log(); - } + LOG.atTrace().setMessage("Long running task changed state: {}{}").addArgument(status::getName) + .addArgument(status).log(); } if(status.getState() == LongRunningTaskStatus.State.DONE) { From df23e79f0b8b8522481083c4ce624fa3ebb9aca7 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 11:39:13 +0200 Subject: [PATCH 129/150] [PR #295] make defensive copy instead of unmodifiable view --- .../VocabularyValidationFinishedEvent.java | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java index 724cc13a9..7c7686ab3 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java @@ -4,9 +4,11 @@ import org.springframework.lang.NonNull; import java.net.URI; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; /** * Indicates that validation for a set of vocabularies was finished. @@ -17,9 +19,9 @@ public class VocabularyValidationFinishedEvent extends VocabularyEvent { * Vocabulary closure of {@link #vocabularyIri}. * IRIs of vocabularies that are imported by {@link #vocabularyIri} and were part of the validation. */ - private final Collection vocabularyIris; + private final List vocabularyIris; - private final Collection validationResults; + private final List validationResults; /** * @param source the source of the event @@ -31,17 +33,18 @@ public VocabularyValidationFinishedEvent(@NonNull Object source, @NonNull URI or @NonNull Collection vocabularyIris, @NonNull List validationResults) { super(source, originVocabularyIri); - this.vocabularyIris = Collections.unmodifiableCollection(vocabularyIris); - this.validationResults = Collections.unmodifiableCollection(validationResults); + // defensive copy + this.vocabularyIris = new ArrayList<>(vocabularyIris); + this.validationResults = new ArrayList<>(validationResults); } @NonNull - public Collection getVocabularyIris() { - return vocabularyIris; + public List getVocabularyIris() { + return Collections.unmodifiableList(vocabularyIris); } @NonNull - public Collection getValidationResults() { - return validationResults; + public List getValidationResults() { + return Collections.unmodifiableList(validationResults); } } From 2d5cd832df8690462efa6029e1a337d1ed4dab17 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 12:01:13 +0200 Subject: [PATCH 130/150] [Performance #285] change long running task registry handling --- .../AllLongRunningTasksCompletedEvent.java | 13 ----- .../event/LongRunningTaskChangedEvent.java | 4 +- .../longrunning/LongRunningTaskScheduler.java | 13 +++-- .../longrunning/LongRunningTaskStatus.java | 3 +- .../longrunning/LongRunningTasksRegistry.java | 52 ++++++++----------- .../termit/util/throttle/ThrottleAspect.java | 9 ++-- .../LongRunningTasksWebSocketController.java | 7 --- .../util/throttle/ThrottleAspectBeanTest.java | 5 ++ .../util/throttle/ThrottleAspectTest.java | 9 ++-- 9 files changed, 48 insertions(+), 67 deletions(-) delete mode 100644 src/main/java/cz/cvut/kbss/termit/event/AllLongRunningTasksCompletedEvent.java diff --git a/src/main/java/cz/cvut/kbss/termit/event/AllLongRunningTasksCompletedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/AllLongRunningTasksCompletedEvent.java deleted file mode 100644 index 257476bc1..000000000 --- a/src/main/java/cz/cvut/kbss/termit/event/AllLongRunningTasksCompletedEvent.java +++ /dev/null @@ -1,13 +0,0 @@ -package cz.cvut.kbss.termit.event; - -import org.springframework.context.ApplicationEvent; - -/** - * Indicates that all long-running tasks were completed and there is not running or pending task. - */ -public class AllLongRunningTasksCompletedEvent extends ApplicationEvent { - - public AllLongRunningTasksCompletedEvent(Object source) { - super(source); - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java index 0353e0e02..202e713bb 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java @@ -12,9 +12,9 @@ public class LongRunningTaskChangedEvent extends ApplicationEvent { private final LongRunningTaskStatus status; - public LongRunningTaskChangedEvent(@NonNull Object source, final @NonNull LongRunningTask longRunningTask) { + public LongRunningTaskChangedEvent(@NonNull Object source, final @NonNull LongRunningTaskStatus status) { super(source); - this.status = new LongRunningTaskStatus(longRunningTask); + this.status = status; } public @NonNull LongRunningTaskStatus getStatus() { diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java index be693e5f1..d4c396f7c 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java @@ -1,7 +1,5 @@ package cz.cvut.kbss.termit.util.longrunning; -import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.lang.NonNull; /** @@ -9,15 +7,16 @@ * @see LongRunningTask */ public abstract class LongRunningTaskScheduler { - protected final ApplicationEventPublisher eventPublisher; + private final LongRunningTasksRegistry registry; - protected LongRunningTaskScheduler(ApplicationEventPublisher eventPublisher) { - this.eventPublisher = eventPublisher; + protected LongRunningTaskScheduler(LongRunningTasksRegistry registry) { + this.registry = registry; } protected final void notifyTaskChanged(final @NonNull LongRunningTask task) { - if (task.getName() != null && !task.getName().isBlank()) { - eventPublisher.publishEvent(new LongRunningTaskChangedEvent(this, task)); + final String name = task.getName(); + if (name != null && !name.isBlank()) { + registry.onTaskChanged(task); } } } diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java index 0a1fdaa70..aa4859c61 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java @@ -5,6 +5,7 @@ import java.io.Serializable; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Objects; import java.util.UUID; @@ -21,7 +22,7 @@ public class LongRunningTaskStatus implements Serializable { public LongRunningTaskStatus(@NonNull LongRunningTask task) { Objects.requireNonNull(task.getName()); this.name = task.getName(); - this.startedAt = task.startedAt().orElse(null); + this.startedAt = task.startedAt().map(time -> time.truncatedTo(ChronoUnit.SECONDS)).orElse(null); this.state = State.of(task); this.uuid = task.getUuid(); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java index 5f899bc03..a73435f4b 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java @@ -1,70 +1,64 @@ package cz.cvut.kbss.termit.util.longrunning; -import cz.cvut.kbss.termit.event.AllLongRunningTasksCompletedEvent; import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.core.annotation.Order; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; -import java.util.Collections; -import java.util.Map; +import java.util.List; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; @Component public class LongRunningTasksRegistry { private static final Logger LOG = LoggerFactory.getLogger(LongRunningTasksRegistry.class); - private final ConcurrentHashMap registry = new ConcurrentHashMap<>(); + private final ConcurrentHashMap registry = new ConcurrentHashMap<>(); private final ApplicationEventPublisher eventPublisher; - private final ReentrantLock lock = new ReentrantLock(true); // using fair ordering - @Autowired public LongRunningTasksRegistry(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } - @Order(0) // ensures that registry is updated before controllers - @EventListener - public void onTaskChanged(final LongRunningTaskChangedEvent event) { - // using fair ReentrantLock guarantees receiving events in the order - lock.lock(); - try { - handleTaskChanged(event); - } finally { - lock.unlock(); - } - } + public void onTaskChanged(@NonNull final LongRunningTask task) { + final LongRunningTaskStatus status = new LongRunningTaskStatus(task); - private void handleTaskChanged(final LongRunningTaskChangedEvent event) { - final LongRunningTaskStatus status = event.getStatus(); if (LOG.isTraceEnabled()) { LOG.atTrace().setMessage("Long running task changed state: {}{}").addArgument(status::getName) .addArgument(status).log(); } - if(status.getState() == LongRunningTaskStatus.State.DONE) { - registry.remove(status.getUuid()); + handleTaskChanged(task); + eventPublisher.publishEvent(new LongRunningTaskChangedEvent(this, status)); + } + + private void handleTaskChanged(@NonNull final LongRunningTask task) { + if(task.isDone()) { + registry.remove(task.getUuid()); } else { - registry.put(status.getUuid(), status); + registry.put(task.getUuid(), task); } - if (registry.isEmpty()) { - eventPublisher.publishEvent(new AllLongRunningTasksCompletedEvent(this)); + + // perform cleanup + registry.forEach((key, value) -> { + if (value.isDone()) { + registry.remove(key); + } + }); + + if (LOG.isTraceEnabled() && registry.isEmpty()) { LOG.trace("All long running tasks completed"); } } @NonNull - public Map getTasks() { - return Collections.unmodifiableMap(registry); + public List getTasks() { + return registry.values().stream().map(LongRunningTaskStatus::new).toList(); } } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index 4cc555b84..b89a77aef 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -6,6 +6,7 @@ import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Pair; import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskScheduler; +import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; @@ -134,8 +135,8 @@ public class ThrottleAspect extends LongRunningTaskScheduler { @Autowired public ThrottleAspect(@Qualifier("longRunningTaskScheduler") TaskScheduler taskScheduler, SynchronousTransactionExecutor transactionExecutor, - ApplicationEventPublisher eventPublisher) { - super(eventPublisher); + LongRunningTasksRegistry longRunningTasksRegistry) { + super(longRunningTasksRegistry); this.taskScheduler = taskScheduler; this.transactionExecutor = transactionExecutor; throttledFutures = new HashMap<>(); @@ -153,8 +154,8 @@ protected ThrottleAspect(Map> throttledFutur Map lastRun, NavigableMap> scheduledFutures, TaskScheduler taskScheduler, Clock clock, SynchronousTransactionExecutor transactionExecutor, - ApplicationEventPublisher eventPublisher) { - super(eventPublisher); + LongRunningTasksRegistry longRunningTasksRegistry) { + super(longRunningTasksRegistry); this.throttledFutures = throttledFutures; this.lastRun = lastRun; this.scheduledFutures = scheduledFutures; diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java index 5a0e0215b..f3d3ac18c 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java @@ -1,6 +1,5 @@ package cz.cvut.kbss.termit.websocket; -import cz.cvut.kbss.termit.event.AllLongRunningTasksCompletedEvent; import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent; import cz.cvut.kbss.termit.security.SecurityConstants; import cz.cvut.kbss.termit.service.IdentifierResolver; @@ -36,12 +35,6 @@ public void tasksRequest(@NonNull MessageHeaders messageHeaders) { sendToSession(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, registry.getTasks(), Map.of(), messageHeaders); } - @EventListener(AllLongRunningTasksCompletedEvent.class) - public void onAllTasksCompleted() { - // sending empty payload - messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, Map.of()); - } - @EventListener(LongRunningTaskChangedEvent.class) public void onTaskChanged() { messagingTemplate.convertAndSend(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, registry.getTasks()); diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java index 57ce4aa7c..987414093 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectBeanTest.java @@ -1,11 +1,13 @@ package cz.cvut.kbss.termit.util.throttle; +import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.test.annotation.DirtiesContext; @@ -36,6 +38,9 @@ class ThrottleAspectBeanTest { @SpyBean ThrottleAspect throttleAspect; + @MockBean + LongRunningTasksRegistry longRunningTasksRegistry; + @Autowired ThrottledService throttledService; diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index ac23c5d12..a3b33ddc5 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -3,6 +3,7 @@ import com.vladsch.flexmark.util.collection.OrderedMap; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.ThrottleAspectException; +import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry; import org.aspectj.lang.ProceedingJoinPoint; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -139,7 +140,7 @@ class ThrottleAspectTest { */ ProceedingJoinPoint joinPointC; - ApplicationEventPublisher eventPublisher; + LongRunningTasksRegistry longRunningTasksRegistry; Clock clock = Clock.fixed(Instant.now(), ZoneId.of("UTC")); @@ -205,9 +206,9 @@ void beforeEach() throws Throwable { when(mockedClock.instant()).then(invocation -> getInstant()); transactionExecutor = spy(SynchronousTransactionExecutor.class); - eventPublisher = mock(ApplicationEventPublisher.class); + longRunningTasksRegistry = mock(LongRunningTasksRegistry.class); - sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock, transactionExecutor, eventPublisher); + sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock, transactionExecutor, longRunningTasksRegistry); } /** @@ -812,7 +813,7 @@ void aspectDownNotThrowsOnEmptyGroup() { @Test void aspectConstructsFromAutowiredConstructor() { - assertDoesNotThrow(() -> new ThrottleAspect(taskScheduler, transactionExecutor, eventPublisher)); + assertDoesNotThrow(() -> new ThrottleAspect(taskScheduler, transactionExecutor, longRunningTasksRegistry)); } @Test From 979e865fde0883df7928ea8fd5beb1921c2a6d0f Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 12:22:14 +0200 Subject: [PATCH 131/150] [PR #295] rename validator method to reflect its purpose --- .../persistence/validation/ResultCachingValidator.java | 5 ++++- .../persistence/validation/ResultCachingValidatorTest.java | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 285f96898..1d6cfe406 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -126,8 +126,11 @@ Validator getValidator() { return null; // Will be replaced by Spring } + /** + * Marks cache related to the vocabulary from the event as dirty + */ @EventListener({VocabularyContentModifiedEvent.class, VocabularyCreatedEvent.class}) - public void evictVocabularyCache(VocabularyEvent event) { + public void markCacheDirty(VocabularyEvent event) { LOG.debug("Vocabulary content modified, marking cache as dirty for {}.", event.getVocabularyIri()); // marked as dirty for specified vocabulary vocabularyClosure.remove(event.getVocabularyIri()); diff --git a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java index f62672c2a..b8cc2e42c 100644 --- a/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java +++ b/src/test/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidatorTest.java @@ -98,7 +98,7 @@ void evictCacheClearsCachedValidationResults() throws Exception { final Set vocabularies = Collections.singleton(vocabulary); final Collection resultOne = runFuture(sut.validate(vocabulary, vocabularies)); verify(validator).validate(vocabulary, vocabularies); - sut.evictVocabularyCache(new VocabularyContentModifiedEvent(this, vocabulary)); + sut.markCacheDirty(new VocabularyContentModifiedEvent(this, vocabulary)); final Collection resultTwo = runFuture(sut.validate(vocabulary, vocabularies)); verify(validator, times(2)).validate(vocabulary, vocabularies); assertEquals(resultOne, resultTwo); From f238fac94355a5392b530569e73292152af598f0 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 12:54:54 +0200 Subject: [PATCH 132/150] [Performance #285] fix exceptional future competition --- .../termit/util/throttle/ThrottleAspect.java | 3 +++ .../termit/util/throttle/ThrottledFuture.java | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index b89a77aef..2666c83ab 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -399,6 +399,9 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id lastRun.put(identifier, Instant.now(clock)); } } finally { + if (!throttledFuture.isDone()) { + throttledFuture.cancel(false); + } notifyTaskChanged(throttledFuture); // task done // clear the security context SecurityContextHolder.clearContext(); diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index a20620398..b3f5c76f3 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -165,6 +165,7 @@ protected ThrottledFuture transfer(ThrottledFuture target) { ThrottledFuture result = target.update(this.task, this.onCompletion); this.task = null; this.onCompletion.clear(); + this.cancel(false); return result; } finally { if (locked) { @@ -195,15 +196,19 @@ protected void run(@Nullable Consumer> startedCallback) { startedCallback.accept(this); } - T result = null; - if (task != null) { - result = task.get(); - final T finalResult = result; - callbackLock.lock(); - onCompletion.forEach(c -> c.accept(finalResult)); - callbackLock.unlock(); + try { + T result = null; + if (task != null) { + result = task.get(); + final T finalResult = result; + callbackLock.lock(); + onCompletion.forEach(c -> c.accept(finalResult)); + callbackLock.unlock(); + } + future.complete(result); + } catch (Exception e) { + future.completeExceptionally(e); } - future.complete(result); } finally { if (locked) { lock.unlock(); From dcc5dfba6b98c3ee120080fd0b6cfb3da8b16886 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 12:59:25 +0200 Subject: [PATCH 133/150] [PR #295] rollback VocabularyController changes --- .../termit/rest/VocabularyController.java | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java index 3b5d52a56..c03272516 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java @@ -262,7 +262,6 @@ public List getHistory( return vocabularyService.getChanges(vocabulary); } - @Operation(security = {@SecurityRequirement(name = "bearer-key")}, description = "Gets summary info about changes made to the content of the vocabulary (term creation, editing).") @ApiResponses({ @@ -325,7 +324,6 @@ public void runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc.ID_LOCAL_N example = ApiDoc.ID_NAMESPACE_EXAMPLE) @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace) { - LOG.warn("Called legacy endpoint intended for internal use or testing only! (/vocabularies/{}/terms/text-analysis)", localName); vocabularyService.runTextAnalysisOnAllTerms(getById(localName, namespace)); } @@ -341,7 +339,6 @@ public void runTextAnalysisOnAllTerms(@Parameter(description = ApiDoc.ID_LOCAL_N @ResponseStatus(HttpStatus.ACCEPTED) @PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "')") public void runTextAnalysisOnAllVocabularies() { - LOG.warn("Called legacy endpoint intended for internal use or testing only! (/vocabularies/text-analysis)"); vocabularyService.runTextAnalysisOnAllVocabularies(); } @@ -384,11 +381,11 @@ public void removeVocabulary(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCR @GetMapping(value = "/{localName}/relations") public List relations(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) - @PathVariable String localName, + @PathVariable String localName, @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, - example = ApiDoc.ID_NAMESPACE_EXAMPLE) - @RequestParam(name = QueryParams.NAMESPACE, - required = false) Optional namespace) { + example = ApiDoc.ID_NAMESPACE_EXAMPLE) + @RequestParam(name = QueryParams.NAMESPACE, + required = false) Optional namespace) { final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); final Vocabulary vocabulary = vocabularyService.findRequired(identifier); @@ -404,11 +401,11 @@ public List relations(@Parameter(description = ApiDoc.ID_LOCAL_NA @GetMapping(value = "/{localName}/terms/relations") public List termsRelations(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION, example = ApiDoc.ID_LOCAL_NAME_EXAMPLE) - @PathVariable String localName, + @PathVariable String localName, @Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, - example = ApiDoc.ID_NAMESPACE_EXAMPLE) - @RequestParam(name = QueryParams.NAMESPACE, - required = false) Optional namespace) { + example = ApiDoc.ID_NAMESPACE_EXAMPLE) + @RequestParam(name = QueryParams.NAMESPACE, + required = false) Optional namespace) { final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); final Vocabulary vocabulary = vocabularyService.findRequired(identifier); @@ -587,15 +584,10 @@ public AccessLevel getAccessLevel(@Parameter(description = ApiDoc.ID_LOCAL_NAME_ * A couple of constants for the {@link VocabularyController} API documentation. */ public static final class ApiDoc { - public static final String ID_LOCAL_NAME_DESCRIPTION = "Locally (in the context of the specified namespace/default vocabulary namespace) unique part of the vocabulary identifier."; - public static final String ID_LOCAL_NAME_EXAMPLE = "datovy-mpp-3.4"; - public static final String ID_NAMESPACE_DESCRIPTION = "Identifier namespace. Allows to override the default vocabulary identifier namespace."; - public static final String ID_NAMESPACE_EXAMPLE = "http://onto.fel.cvut.cz/ontologies/slovnik/"; - public static final String ID_NOT_FOUND_DESCRIPTION = "Vocabulary with the specified identifier not found."; private ApiDoc() { From 6b06c22d64c39e316d201fd0992a2720d42ddf1a Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 13:18:42 +0200 Subject: [PATCH 134/150] [PR #295] merge SynchronousTermOccurrenceSaver with TermOccurrenceSaver interface and fix faulty throttle aspect test --- .../rest/handler/RestExceptionHandler.java | 2 +- .../SynchronousTermOccurrenceSaver.java | 90 ------------------- .../service/document/TermOccurrenceSaver.java | 79 ++++++++++++++-- ...Test.java => TermOccurrenceSaverTest.java} | 4 +- .../util/throttle/ThrottleAspectTest.java | 14 ++- 5 files changed, 85 insertions(+), 104 deletions(-) delete mode 100644 src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java rename src/test/java/cz/cvut/kbss/termit/service/document/{SynchronousTermOccurrenceSaverTest.java => TermOccurrenceSaverTest.java} (93%) diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index beec29b34..c1a170355 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -84,7 +84,7 @@ private static void logException(Throwable ex, HttpServletRequest request) { } private static void logException(String message, Throwable ex) { - // prevents from logging exceptions caused be broken connection with a client + // Prevents exceptions caused by broken connection with a client from logging if (!isCausedBy(ex, AsyncRequestNotUsableException.class)) { LOG.error(message, ex); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java deleted file mode 100644 index 53a2ee46e..000000000 --- a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java +++ /dev/null @@ -1,90 +0,0 @@ -package cz.cvut.kbss.termit.service.document; - -import cz.cvut.kbss.termit.exception.TermItException; -import cz.cvut.kbss.termit.model.Asset; -import cz.cvut.kbss.termit.model.assignment.TermOccurrence; -import cz.cvut.kbss.termit.persistence.dao.TermOccurrenceDao; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Saves occurrences synchronously. - *

    - * Existing occurrences are reused if they match. - */ -@Service -public class SynchronousTermOccurrenceSaver implements TermOccurrenceSaver { - - private static final Logger LOG = LoggerFactory.getLogger(SynchronousTermOccurrenceSaver.class); - - private final TermOccurrenceDao termOccurrenceDao; - - public SynchronousTermOccurrenceSaver(TermOccurrenceDao termOccurrenceDao) { - this.termOccurrenceDao = termOccurrenceDao; - } - - @Transactional - @Override - public void saveOccurrences(List occurrences, Asset source) { - LOG.debug("Saving term occurrences for asset {}.", source); - removeAll(source); - LOG.trace("Persisting new occurrences in {}.", source); - occurrences.stream().filter(o -> !o.getTerm().equals(source.getUri())).forEach(termOccurrenceDao::persist); - } - - @Override - public void saveOccurrence(TermOccurrence occurrence, Asset source) { - if (occurrence.getTerm().equals(source.getUri())) { - return; - } - if(!termOccurrenceDao.exists(occurrence.getUri())) { - termOccurrenceDao.persist(occurrence); - } else { - LOG.debug("Occurrence already exists, skipping: {}", occurrence); - } - } - - @Transactional - @Override - public void saveFromQueue(final Asset source, final AtomicBoolean finished, - final BlockingQueue toSave) { - LOG.debug("Saving term occurrences for asset {}.", source); - removeAll(source); - TermOccurrence occurrence; - long count = 0; - try { - while (!finished.get() || !toSave.isEmpty()) { - if (toSave.isEmpty()) { - Thread.yield(); - } - occurrence = toSave.poll(1, TimeUnit.SECONDS); - if (occurrence != null) { - saveOccurrence(occurrence, source); - count++; - } - } - LOG.debug("Saved {} term occurrences for assert {}.", count, source); - } catch (InterruptedException e) { - LOG.error("Thread interrupted while waiting for occurrences to save."); - Thread.currentThread().interrupt(); - throw new TermItException(e); - } - } - - @Override - public List getExistingOccurrences(Asset source) { - return termOccurrenceDao.findAllTargeting(source); - } - - private void removeAll(Asset source) { - LOG.trace("Removing all existing occurrences in asset {}.", source); - termOccurrenceDao.removeAll(source); - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java index 99d208715..9843a9864 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java @@ -1,16 +1,34 @@ package cz.cvut.kbss.termit.service.document; +import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.model.Asset; import cz.cvut.kbss.termit.model.assignment.TermOccurrence; +import cz.cvut.kbss.termit.persistence.dao.TermOccurrenceDao; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; /** - * Saves occurrences of terms. + * Saves occurrences synchronously. + *

    + * Existing occurrences are reused if they match. */ -public interface TermOccurrenceSaver { +@Service +public class TermOccurrenceSaver { + + private static final Logger LOG = LoggerFactory.getLogger(TermOccurrenceSaver.class); + + private final TermOccurrenceDao termOccurrenceDao; + + public TermOccurrenceSaver(TermOccurrenceDao termOccurrenceDao) { + this.termOccurrenceDao = termOccurrenceDao; + } /** * Saves the specified occurrences of terms in the specified asset. @@ -22,7 +40,24 @@ public interface TermOccurrenceSaver { * @param occurrences Occurrences to save * @param source Asset in which the terms occur */ - void saveOccurrences(List occurrences, Asset source); + @Transactional + public void saveOccurrences(List occurrences, Asset source) { + LOG.debug("Saving term occurrences for asset {}.", source); + removeAll(source); + LOG.trace("Persisting new occurrences in {}.", source); + occurrences.stream().filter(o -> !o.getTerm().equals(source.getUri())).forEach(termOccurrenceDao::persist); + } + + public void saveOccurrence(TermOccurrence occurrence, Asset source) { + if (occurrence.getTerm().equals(source.getUri())) { + return; + } + if(!termOccurrenceDao.exists(occurrence.getUri())) { + termOccurrenceDao.persist(occurrence); + } else { + LOG.debug("Occurrence already exists, skipping: {}", occurrence); + } + } /** * Continously saves occurrences from the queue while blocking current thread until @@ -34,10 +69,31 @@ public interface TermOccurrenceSaver { * @param finished Whether all occurrences were added to the queue * @param toSave the queue with occurrences to save */ - void saveFromQueue(final Asset source, final AtomicBoolean finished, - final BlockingQueue toSave); - - void saveOccurrence(TermOccurrence occurrence, Asset source); + @Transactional + public void saveFromQueue(final Asset source, final AtomicBoolean finished, + final BlockingQueue toSave) { + LOG.debug("Saving term occurrences for asset {}.", source); + removeAll(source); + TermOccurrence occurrence; + long count = 0; + try { + while (!finished.get() || !toSave.isEmpty()) { + if (toSave.isEmpty()) { + Thread.yield(); + } + occurrence = toSave.poll(1, TimeUnit.SECONDS); + if (occurrence != null) { + saveOccurrence(occurrence, source); + count++; + } + } + LOG.debug("Saved {} term occurrences for assert {}.", count, source); + } catch (InterruptedException e) { + LOG.error("Thread interrupted while waiting for occurrences to save."); + Thread.currentThread().interrupt(); + throw new TermItException(e); + } + } /** * Gets a list of existing term occurrences in the specified asset. @@ -45,5 +101,12 @@ void saveFromQueue(final Asset source, final AtomicBoolean finished, * @param source Asset in which the terms occur * @return List of existing term occurrences */ - List getExistingOccurrences(Asset source); + public List getExistingOccurrences(Asset source) { + return termOccurrenceDao.findAllTargeting(source); + } + + private void removeAll(Asset source) { + LOG.trace("Removing all existing occurrences in asset {}.", source); + termOccurrenceDao.removeAll(source); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaverTest.java b/src/test/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaverTest.java similarity index 93% rename from src/test/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaverTest.java rename to src/test/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaverTest.java index c335e27b5..9f02b3f8f 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaverTest.java @@ -17,13 +17,13 @@ import static org.mockito.Mockito.inOrder; @ExtendWith(MockitoExtension.class) -class SynchronousTermOccurrenceSaverTest { +class TermOccurrenceSaverTest { @Mock private TermOccurrenceDao occurrenceDao; @InjectMocks - private SynchronousTermOccurrenceSaver sut; + private TermOccurrenceSaver sut; @Test void saveOccurrencesRemovesAllExistingOccurrencesAndPersistsSpecifiedOnes() { diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index a3b33ddc5..e31b5e613 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -742,20 +742,26 @@ void exceptionPropagatedWhenJoinPointProceedThrows() throws Throwable { assertDoesNotThrow(() -> taskSchedulerTasks.forEach((r, i) -> r.run())); } + /** + * Ensures that when an exception is thrown during throttled task execution, + * it is stored within the future and rethrown on future#get. + */ @Test void exceptionPropagatedFromFutureTask() throws Throwable { final String exceptionMessage = "termit exception"; - when(joinPointA.proceed()).then(invocation -> new ThrottledFuture<>().update(() -> { + when(joinPointA.proceed()).then(invocation -> ThrottledFuture.of(() -> { throw new TermItException(exceptionMessage); - }, List.of())); + })); signatureA.setReturnType(Future.class); sut.throttleMethodCall(joinPointA, throttleA); assertEquals(1, taskSchedulerTasks.size()); assertEquals(1, scheduledFutures.size()); + assertEquals(1, throttledFutures.size()); Runnable scheduled = taskSchedulerTasks.getKey(0); - Future future = scheduledFutures.firstEntry().getValue(); + Future scheduledFuture = scheduledFutures.firstEntry().getValue(); + Future future = throttledFutures.getValue(0); assertNotNull(scheduled); assertNotNull(future); @@ -763,6 +769,8 @@ void exceptionPropagatedFromFutureTask() throws Throwable { // exception is then re-thrown during future#get() ExecutionException e = assertThrows(ExecutionException.class, future::get); assertEquals(exceptionMessage, e.getCause().getMessage()); + assertTrue(scheduledFuture.isDone()); + assertTrue(future.isDone()); } @Test From c3968953093ca98f7c07655327f5653b574e0ed2 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 13:26:29 +0200 Subject: [PATCH 135/150] [Performance #285] fix faulty test and ensure that the task is null after transferring out of throttled future --- .../cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java | 1 + .../cvut/kbss/termit/util/throttle/ThrottledFutureTest.java | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index b3f5c76f3..35947b403 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -148,6 +148,7 @@ protected ThrottledFuture update(Supplier task, @NonNull List> /** * Returns future with the task from the specified {@code throttledFuture}. * If possible, transfers the task from this object to the specified {@code throttledFuture}. + * If the task was successfully transferred, this future is canceled. * * @param target the future to update * @return target when current future is already being executed, was canceled or completed. diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java index ff4f66f72..29c7f84ce 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java @@ -281,8 +281,9 @@ void transferUpdatesSecondFutureWithTask() { verify(secondFuture).update(eq(firstTask), anyList()); // now verifies that the task in the first future is null - firstFuture.transfer(secondFuture); - verify(secondFuture).update(isNull(), anyList()); + Object task = ReflectionTestUtils.getField(firstFuture, "task"); + assertNull(task); + assertTrue(firstFuture.isCancelled()); } @Test From 1104c60b0151f10cc82cda55c27d319c763fe588 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 14:26:14 +0200 Subject: [PATCH 136/150] [PR #295] remove parallel stream --- .../termit/service/document/html/HtmlSelectorGenerators.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlSelectorGenerators.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlSelectorGenerators.java index 2573c9ce5..bf09c6ba2 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlSelectorGenerators.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlSelectorGenerators.java @@ -52,6 +52,6 @@ public HtmlSelectorGenerators(Configuration config) { * @return Set of generated selectors */ public Set generateSelectors(Element... elements) { - return generators.parallelStream().map(g -> g.generateSelector(elements)).collect(Collectors.toSet()); + return generators.stream().map(g -> g.generateSelector(elements)).collect(Collectors.toSet()); } } From d8555ac27e78c34ef62eb94b35c6fb702a6666e6 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 14:27:19 +0200 Subject: [PATCH 137/150] [PR #295] optimize imports --- .../cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java | 1 - .../kbss/termit/event/VocabularyValidationFinishedEvent.java | 1 - .../java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java | 1 - .../cvut/kbss/termit/websocket/VocabularySocketController.java | 2 +- .../cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java | 2 -- .../cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java | 1 - 6 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java index 202e713bb..fd3cf7af1 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java @@ -1,6 +1,5 @@ package cz.cvut.kbss.termit.event; -import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskStatus; import org.springframework.context.ApplicationEvent; import org.springframework.lang.NonNull; diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java index 7c7686ab3..a5af0bbe8 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java @@ -8,7 +8,6 @@ import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Map; /** * Indicates that validation for a set of vocabularies was finished. diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index 2666c83ab..32a0357a7 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -14,7 +14,6 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Scope; import org.springframework.core.annotation.Order; diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index 0f17f8c21..57578f45b 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -1,8 +1,8 @@ package cz.cvut.kbss.termit.websocket; -import cz.cvut.kbss.termit.event.VocabularyEvent; import cz.cvut.kbss.termit.event.FileTextAnalysisFinishedEvent; import cz.cvut.kbss.termit.event.TermDefinitionTextAnalysisFinishedEvent; +import cz.cvut.kbss.termit.event.VocabularyEvent; import cz.cvut.kbss.termit.event.VocabularyValidationFinishedEvent; import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.model.validation.ValidationResult; diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index e31b5e613..55486ebaf 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.expression.spel.SpelParseException; import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.support.TaskUtils; @@ -20,7 +19,6 @@ import java.time.Instant; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.List; import java.util.Map; import java.util.NavigableMap; import java.util.Optional; diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java index 29c7f84ce..bf8f4f4e0 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottledFutureTest.java @@ -28,7 +28,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.notNull; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; From 5c52b0f86f005de3d4620a5310d55d810695637e Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 14:36:00 +0200 Subject: [PATCH 138/150] [PR #295] add Javadoc and prevent useless lambda redefinition in loop --- .../html/TextPositionSelectorGenerator.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java index 30808f52b..bbd47d34f 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java @@ -52,6 +52,9 @@ public TextPositionSelector generateSelector(Element... elements) { } /** + * This code was extracted from {@link #extractNodeText} and related functions + * to prevent constructing whole string contents for only getting its length. + * Now only length is counted from the contents of text nodes. * @see SelectorGenerator#extractNodeText(Iterable) * @see Element#wholeText() * @see TextNode#getWholeText() @@ -59,19 +62,20 @@ public TextPositionSelector generateSelector(Element... elements) { private int resolveStartPosition(Element element) { final Elements ancestors = element.parents(); Element previous = element; + // atomic required for access from lambda AtomicInteger counter = new AtomicInteger(); + NodeVisitor consumer = (node, depth) -> { + if (node instanceof TextNode textNode) { + counter.addAndGet(textNode.getWholeText().length()); + } else if (node.normalName().equals("br")) { + counter.getAndIncrement(); + } + }; for (Element parent : ancestors) { final List previousSiblings = parent.childNodes().subList(0, previous.siblingIndex()); for (final Node sibling : previousSiblings) { - NodeVisitor consumer = (node, depth) -> { - if (node instanceof TextNode textNode) { - counter.addAndGet(textNode.getWholeText().length()); - } else if (node.normalName().equals("br")) { - counter.getAndIncrement(); - } - }; - NodeTraversor.traverse(consumer, sibling); + NodeTraversor.traverse(consumer, sibling); } previous = parent; From 6c21f4edd85b78a63b6e4acb05b258ac7a83b2e5 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 16:16:12 +0200 Subject: [PATCH 139/150] [PR #295] revert unsuccessfully attempt to optimize memory usage, resolve silenced exception from throttled tasks --- .../html/TextPositionSelectorGenerator.java | 18 +++--------------- .../termit/util/throttle/ThrottleAspect.java | 11 +++++++++-- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java index bbd47d34f..767e06676 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java @@ -62,24 +62,12 @@ public TextPositionSelector generateSelector(Element... elements) { private int resolveStartPosition(Element element) { final Elements ancestors = element.parents(); Element previous = element; - // atomic required for access from lambda - AtomicInteger counter = new AtomicInteger(); - NodeVisitor consumer = (node, depth) -> { - if (node instanceof TextNode textNode) { - counter.addAndGet(textNode.getWholeText().length()); - } else if (node.normalName().equals("br")) { - counter.getAndIncrement(); - } - }; + int counter = 0; for (Element parent : ancestors) { final List previousSiblings = parent.childNodes().subList(0, previous.siblingIndex()); - - for (final Node sibling : previousSiblings) { - NodeTraversor.traverse(consumer, sibling); - } - + counter += extractNodeText(previousSiblings).length(); previous = parent; } - return counter.get(); + return counter; } } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index 32a0357a7..863ed6114 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -311,6 +311,7 @@ private Pair> getFutureTask(@NonNull Proceedin final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); final Class returnType = methodSignature.getReturnType(); final boolean isFuture = returnType.isAssignableFrom(ThrottledFuture.class); + final boolean isVoid = returnType.equals(Void.class) || returnType.equals(Void.TYPE); ThrottledFuture throttledFuture = future; @@ -335,15 +336,21 @@ private Pair> getFutureTask(@NonNull Proceedin } else { throw new ThrottleAspectException("Returned value is not a ThrottledFuture"); } - } else { + } else if (isVoid) { throttledFuture = throttledFuture.update(() -> { try { return joinPoint.proceed(); } catch (Throwable e) { - // exception happened inside throttled method + // exception happened inside throttled method, + // and the method returns null, if we rethrow the exception + // it will be stored inside the future + // and never retrieved (as the method returned null) + LOG.error("Exception thrown during task execution", e); throw new TermItException(e); } }, List.of()); + } else { + throw new ThrottleAspectException("Invalid return type for " + joinPoint.getSignature() + " annotated with @Debounce, only Future or void allowed!"); } final boolean withTransaction = methodSignature.getMethod() != null && methodSignature.getMethod() From f3b9d9abf1577b09b44448a23718d5531a2b83b8 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 12 Sep 2024 16:47:05 +0200 Subject: [PATCH 140/150] [PR #295] move throttle constants to configuration --- .../cvut/kbss/termit/util/Configuration.java | 37 +++++++++++++++++++ .../cz/cvut/kbss/termit/util/Constants.java | 16 -------- .../termit/util/throttle/ThrottleAspect.java | 28 +++++++------- .../util/throttle/ThrottleAspectTest.java | 18 ++++----- .../ThrottleAspectTestContextConfig.java | 6 +++ 5 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java index 2ee79a08d..cf609cab8 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java @@ -18,7 +18,9 @@ package cz.cvut.kbss.termit.util; import cz.cvut.kbss.termit.model.acl.AccessLevel; +import cz.cvut.kbss.termit.util.throttle.ThrottleAspect; import jakarta.validation.Valid; +import jakarta.validation.constraints.Future; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -63,6 +65,25 @@ public class Configuration { */ @Min(1) private Integer asyncThreadCount = Runtime.getRuntime().availableProcessors(); + + /** + * The amount of time in which calls of throttled methods + * should be merged. + * The value must be positive ({@code > 0}). + * @configurationdoc.default 10 seconds + * @see cz.cvut.kbss.termit.util.throttle.Throttle + * @see cz.cvut.kbss.termit.util.throttle.ThrottleAspect + */ + private Duration throttleThreshold = Duration.ofSeconds(10); + + /** + * After how much time, should objects with completed futures be discarded. + * The value must be positive ({@code > 0}). + * @configurationdoc.default 1 minute + * @see ThrottleAspect#clearOldFutures() + */ + private Duration throttleDiscardThreshold = Duration.ofMinutes(1); + @Valid private Persistence persistence = new Persistence(); @Valid @@ -278,6 +299,22 @@ public void setTemplate(Template template) { this.template = template; } + public Duration getThrottleThreshold() { + return throttleThreshold; + } + + public void setThrottleThreshold(Duration throttleThreshold) { + this.throttleThreshold = throttleThreshold; + } + + public Duration getThrottleDiscardThreshold() { + return throttleDiscardThreshold; + } + + public void setThrottleDiscardThreshold(Duration throttleDiscardThreshold) { + this.throttleDiscardThreshold = throttleDiscardThreshold; + } + @Validated public static class Persistence { /** diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index 1930a53c6..601c4703f 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -48,22 +48,6 @@ public class Constants { */ public static final String REST_MAPPING_PATH = "/rest"; - /** - * The amount of time in which calls of methods - * with {@link cz.cvut.kbss.termit.util.throttle.Throttle} annotation - * should be merged. - * - * @see cz.cvut.kbss.termit.util.throttle.Throttle - * @see cz.cvut.kbss.termit.util.throttle.ThrottleAspect - */ - public static final Duration THROTTLE_THRESHOLD = Duration.ofSeconds(10); - - /** - * After how much time, should complete futures be discarded. - * @see ThrottleAspect#clearOldFutures() - */ - public static final Duration THROTTLE_DISCARD_THRESHOLD = Duration.ofMinutes(1); - /** * Default page size. *

    diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index 863ed6114..fcf8ea14b 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -3,7 +3,7 @@ import cz.cvut.kbss.termit.TermItApplication; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.ThrottleAspectException; -import cz.cvut.kbss.termit.util.Constants; +import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Pair; import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskScheduler; import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry; @@ -53,8 +53,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static cz.cvut.kbss.termit.util.Constants.THROTTLE_DISCARD_THRESHOLD; -import static cz.cvut.kbss.termit.util.Constants.THROTTLE_THRESHOLD; import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_SINGLETON; /** @@ -131,13 +129,16 @@ public class ThrottleAspect extends LongRunningTaskScheduler { */ private final AtomicReference lastClear; + private final Configuration configuration; + @Autowired public ThrottleAspect(@Qualifier("longRunningTaskScheduler") TaskScheduler taskScheduler, SynchronousTransactionExecutor transactionExecutor, - LongRunningTasksRegistry longRunningTasksRegistry) { + LongRunningTasksRegistry longRunningTasksRegistry, Configuration configuration) { super(longRunningTasksRegistry); this.taskScheduler = taskScheduler; this.transactionExecutor = transactionExecutor; + this.configuration = configuration; throttledFutures = new HashMap<>(); lastRun = new HashMap<>(); scheduledFutures = new TreeMap<>(); @@ -153,7 +154,7 @@ protected ThrottleAspect(Map> throttledFutur Map lastRun, NavigableMap> scheduledFutures, TaskScheduler taskScheduler, Clock clock, SynchronousTransactionExecutor transactionExecutor, - LongRunningTasksRegistry longRunningTasksRegistry) { + LongRunningTasksRegistry longRunningTasksRegistry, Configuration configuration) { super(longRunningTasksRegistry); this.throttledFutures = throttledFutures; this.lastRun = lastRun; @@ -161,6 +162,7 @@ protected ThrottleAspect(Map> throttledFutur this.taskScheduler = taskScheduler; this.clock = clock; this.transactionExecutor = transactionExecutor; + this.configuration = configuration; standardEvaluationContext = makeDefaultContext(); lastClear = new AtomicReference<>(Instant.now(clock)); } @@ -231,7 +233,7 @@ private static StandardEvaluationContext makeDefaultContext() { } } - // if there is a scheduled task and this throttled instance was executed in the last THROTTLE_THRESHOLD + // if there is a scheduled task and this throttled instance was executed in the last configuration.getThrottleThreshold() // cancel the scheduled task // -> the execution is further delayed Future oldScheduledFuture = scheduledFutures.get(identifier); @@ -423,13 +425,13 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id /** * Discards futures from {@link #throttledFutures}, {@link #lastRun} and {@link #scheduledFutures} maps. - *

    Every completed future for which a {@link Constants#THROTTLE_DISCARD_THRESHOLD} expired is discarded.

    + *

    Every completed future for which a {@link Configuration#throttleDiscardThreshold throttleDiscardThreshold} expired is discarded.

    * @see #isThresholdExpired(Identifier) */ private void clearOldFutures() { // if the last clear was performed less than a threshold ago, skip it for now Instant last = lastClear.get(); - if (last.isAfter(Instant.now(clock).minus(THROTTLE_THRESHOLD).minus(THROTTLE_DISCARD_THRESHOLD))) { + if (last.isAfter(Instant.now(clock).minus(configuration.getThrottleThreshold()).minus(configuration.getThrottleDiscardThreshold()))) { return; } if (!lastClear.compareAndSet(last, Instant.now(clock))) { @@ -442,7 +444,7 @@ private void clearOldFutures() { .stream()) .flatMap(s -> s).distinct().toList() // ensures safe modification of maps .forEach(identifier -> { - if (isThresholdExpiredByMoreThan(identifier, THROTTLE_DISCARD_THRESHOLD)) { + if (isThresholdExpiredByMoreThan(identifier, configuration.getThrottleDiscardThreshold())) { Optional.ofNullable(throttledFutures.get(identifier)).ifPresent(throttled -> { if (throttled.isDone()) { throttledFutures.remove(identifier); @@ -465,10 +467,10 @@ private void clearOldFutures() { * @param identifier of the task * @param duration to add to the throttle threshold * @return Whether the last time when a task with specified {@code identifier} run - * is older than ({@link Constants#THROTTLE_THRESHOLD} + {@code duration}) + * is older than ({@link Configuration#throttleThreshold throttleThreshold} + {@code duration}) */ private boolean isThresholdExpiredByMoreThan(Identifier identifier, Duration duration) { - return lastRun.getOrDefault(identifier, Instant.MAX).isBefore(Instant.now(clock).minus(THROTTLE_THRESHOLD).minus(duration)); + return lastRun.getOrDefault(identifier, Instant.MAX).isBefore(Instant.now(clock).minus(configuration.getThrottleThreshold()).minus(duration)); } /** @@ -476,12 +478,12 @@ private boolean isThresholdExpiredByMoreThan(Identifier identifier, Duration dur * true when the task had never run */ private boolean isThresholdExpired(Identifier identifier) { - return lastRun.getOrDefault(identifier, Instant.EPOCH).isBefore(Instant.now(clock).minus(THROTTLE_THRESHOLD)); + return lastRun.getOrDefault(identifier, Instant.EPOCH).isBefore(Instant.now(clock).minus(configuration.getThrottleThreshold())); } @SuppressWarnings("unchecked") private void schedule(Identifier identifier, Runnable task, boolean immediately) { - Instant startTime = Instant.now(clock).plus(THROTTLE_THRESHOLD); + Instant startTime = Instant.now(clock).plus(configuration.getThrottleThreshold()); if (immediately) { startTime = Instant.now(clock); } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java index 55486ebaf..3200ecd2b 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTest.java @@ -3,6 +3,7 @@ import com.vladsch.flexmark.util.collection.OrderedMap; import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.exception.ThrottleAspectException; +import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry; import org.aspectj.lang.ProceedingJoinPoint; import org.junit.jupiter.api.BeforeEach; @@ -33,8 +34,6 @@ import java.util.function.Supplier; import java.util.stream.Stream; -import static cz.cvut.kbss.termit.util.Constants.THROTTLE_DISCARD_THRESHOLD; -import static cz.cvut.kbss.termit.util.Constants.THROTTLE_THRESHOLD; import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -56,6 +55,7 @@ class ThrottleAspectTest { private static final long THREAD_JOIN_TIMEOUT_MILLIS = 60 * 1000; + private final Configuration configuration = new Configuration(); /** * Throttled futures from {@link #sut} @@ -206,7 +206,7 @@ void beforeEach() throws Throwable { transactionExecutor = spy(SynchronousTransactionExecutor.class); longRunningTasksRegistry = mock(LongRunningTasksRegistry.class); - sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock, transactionExecutor, longRunningTasksRegistry); + sut = new ThrottleAspect(throttledFutures, lastRun, scheduledFutures, taskScheduler, mockedClock, transactionExecutor, longRunningTasksRegistry, configuration); } /** @@ -221,13 +221,13 @@ void addSecond() { } void skipThreshold() { - clock = Clock.fixed(clock.instant().plus(THROTTLE_THRESHOLD), ZoneId.of("UTC")); + clock = Clock.fixed(clock.instant().plus(configuration.getThrottleThreshold()), ZoneId.of("UTC")); } void skipDiscardThreshold() { clock = Clock.fixed(clock.instant() - .plus(THROTTLE_DISCARD_THRESHOLD) - .plus(THROTTLE_THRESHOLD) + .plus(configuration.getThrottleDiscardThreshold()) + .plus(configuration.getThrottleThreshold()) .plusSeconds(1), ZoneId.of("UTC")); } @@ -618,8 +618,8 @@ void taskFromMethodAnnotatedWithTransactionalIsExecutedWithTransactionExecutor() /** * When a task is executed, all three maps are cleared from - * entries older than {@link cz.cvut.kbss.termit.util.Constants#THROTTLE_DISCARD_THRESHOLD THROTTLE_DISCARD_THRESHOLD} - * plus {@link cz.cvut.kbss.termit.util.Constants#THROTTLE_THRESHOLD THROTTLE_THRESHOLD} + * entries older than {@link Configuration#throttleDiscardThreshold throttleDiscardThreshold} + * plus {@link Configuration#throttleThreshold throttleThreshold} */ @Test void allMapsAreClearedAfterDiscardThreshold() throws Throwable { @@ -819,7 +819,7 @@ void aspectDownNotThrowsOnEmptyGroup() { @Test void aspectConstructsFromAutowiredConstructor() { - assertDoesNotThrow(() -> new ThrottleAspect(taskScheduler, transactionExecutor, longRunningTasksRegistry)); + assertDoesNotThrow(() -> new ThrottleAspect(taskScheduler, transactionExecutor, longRunningTasksRegistry, configuration)); } @Test diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java index 695b9b0d5..029cc0455 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspectTestContextConfig.java @@ -1,5 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; +import cz.cvut.kbss.termit.util.Configuration; import org.mockito.Mockito; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -27,4 +28,9 @@ public ThreadPoolTaskScheduler longRunningTaskScheduler() { public ThrottledService throttledService() { return new ThrottledService(); } + + @Bean + public Configuration configuration() { + return new Configuration(); + } } From dd6aff50f9139df9d486d1b205989e9eb782ca52 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 13 Sep 2024 09:58:22 +0200 Subject: [PATCH 141/150] [Fix] Minor transactional behavior fix. --- .../java/cz/cvut/kbss/termit/service/business/TermService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index 2ca3aba18..db3bb6564 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -51,6 +51,7 @@ import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.net.URI; @@ -442,7 +443,7 @@ public void remove(@NonNull Term term) { @Throttle(value = "{#vocabularyIri, #term.getUri()}", group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyTerm(#vocabulary.getUri(), #term.getUri())", name="termDefinitionAnalysis") - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) @PreAuthorize("@termAuthorizationService.canModify(#term)") public void analyzeTermDefinition(AbstractTerm term, URI vocabularyIri) { term = findRequired(term.getUri()); // required when throttling for persistent context From ff53cfc73025d1439d1566a8a0de9d7a76939b83 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Fri, 13 Sep 2024 11:22:35 +0200 Subject: [PATCH 142/150] [Enhancement kbss-cvut/termit-ui#505] Introduce support for parametrized localized messages in errors. Any TermIt exception can now carry a messageId for localized message on the frontend as well as a map of parameters for the message. Parameters are sent to the frontend in the ErrorInfo as values map. --- .../termit/exception/TermItException.java | 59 ++++++++++++++++--- .../importing/VocabularyImportException.java | 11 +--- .../kbss/termit/rest/handler/ErrorInfo.java | 42 ++++++++++++- .../rest/handler/RestExceptionHandler.java | 6 +- .../service/importer/excel/ExcelImporter.java | 5 +- .../java/cz/cvut/kbss/termit/util/Utils.java | 15 +++++ .../handler/WebSocketExceptionHandler.java | 2 +- 7 files changed, 115 insertions(+), 25 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java b/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java index 0477c6b7f..526ae80bc 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java @@ -17,6 +17,13 @@ */ package cz.cvut.kbss.termit.exception; +import cz.cvut.kbss.termit.util.Utils; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +import java.util.HashMap; +import java.util.Map; + /** * Application-specific exception. *

    @@ -25,15 +32,34 @@ public class TermItException extends RuntimeException { /** - * Identifier of localized frontend message. + * Error message identifier. + *

    + * This identifier can be used by the UI to display a corresponding localized error message. + */ + protected String messageId; + + /** + * Exception related information */ - private String messageId; + protected final Map parameters = new HashMap<>(); protected TermItException() { + messageId = null; } public TermItException(String message) { super(message); + messageId = null; + } + + public TermItException(String message, Throwable cause) { + super(message, cause); + this.messageId = null; + } + + public TermItException(Throwable cause) { + super(cause); + this.messageId = null; } public TermItException(String message, String messageId) { @@ -41,24 +67,41 @@ public TermItException(String message, String messageId) { this.messageId = messageId; } - public TermItException(String message, Throwable cause) { + public TermItException(String message, Throwable cause, String messageId) { super(message, cause); + this.messageId = messageId; } - public TermItException(String message, String messageId, Throwable cause) { + public TermItException(String message, Throwable cause, String messageId, @NonNull Map parameters) { super(message, cause); this.messageId = messageId; + addParameters(parameters); } - public TermItException(Throwable cause) { - super(cause); + public TermItException addParameters(@NonNull Map parameters) { + this.parameters.putAll(parameters); + return this; } + public TermItException addParameter(@NonNull String key, @NonNull String value) { + this.parameters.put(key, value); + return this; + } + + @Nullable public String getMessageId() { return messageId; } - public void setMessageId(String messageId) { - this.messageId = messageId; + public Map getParameters() { + return parameters; + } + + @Override + public String toString() { + String params = Utils.mapToString(parameters); + return super.toString() + + (messageId == null ? "" : ", messageId=" + messageId) + + (params.isBlank() ? "" : ", parameters=" + params); } } diff --git a/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java index 9a0dbca94..8a3426c8b 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/importing/VocabularyImportException.java @@ -24,24 +24,15 @@ */ public class VocabularyImportException extends TermItException { - private final String messageId; - public VocabularyImportException(String message) { super(message); - this.messageId = null; } public VocabularyImportException(String message, String messageId) { - super(message); - this.messageId = messageId; + super(message, messageId); } public VocabularyImportException(String message, Throwable cause) { super(message, cause); - this.messageId = null; - } - - public String getMessageId() { - return messageId; } } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/ErrorInfo.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/ErrorInfo.java index 32ba557b1..94c4ec151 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/ErrorInfo.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/ErrorInfo.java @@ -17,17 +17,34 @@ */ package cz.cvut.kbss.termit.rest.handler; +import cz.cvut.kbss.termit.util.Utils; + +import java.util.Map; + /** * Contains information about an error and can be sent to client as JSON to let him know what is wrong. */ public class ErrorInfo { + /** + * Readable message describing the error + */ private String message; + /** + * Error message identifier. + *

    + * This identifier can be used by the UI to display a corresponding localized error message. + */ private String messageId; private String requestUri; + /** + * Parameters for translatable message identified by {@link #messageId} + */ + private Map values; + public ErrorInfo() { } @@ -66,9 +83,17 @@ public void setRequestUri(String requestUri) { this.requestUri = requestUri; } + public Map getValues() { + return values; + } + + public void setValues(Map parameters) { + this.values = parameters; + } + @Override public String toString() { - return "ErrorInfo{" + requestUri + ", messageId=" + messageId + ", message='" + message + "'}"; + return "ErrorInfo{" + requestUri + ", messageId=" + messageId + ", message='" + message + "', parameters='"+ Utils.mapToString(values) +"'}"; } /** @@ -101,4 +126,19 @@ public static ErrorInfo createWithMessageAndMessageId(String message, String mes errorInfo.setMessageId(messageId); return errorInfo; } + + public static ErrorInfo createParametrized(String messageId, String requestUri, Map values) { + final ErrorInfo errorInfo = new ErrorInfo(requestUri); + errorInfo.setMessageId(messageId); + errorInfo.setValues(values); + return errorInfo; + } + + public static ErrorInfo createParametrizedWithMessage(String message, String messageId, String requestUri, Map values) { + final ErrorInfo errorInfo = new ErrorInfo(requestUri); + errorInfo.setMessage(message); + errorInfo.setMessageId(messageId); + errorInfo.setValues(values); + return errorInfo; + } } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index c1a170355..1b9d689ff 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -95,7 +95,7 @@ private static ErrorInfo errorInfo(HttpServletRequest request, Throwable e) { } private static ErrorInfo errorInfo(HttpServletRequest request, TermItException e) { - return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), request.getRequestURI()); + return ErrorInfo.createParametrizedWithMessage(e.getMessage(), e.getMessageId(), request.getRequestURI(), e.getParameters()); } @ExceptionHandler(PersistenceException.class) @@ -191,9 +191,7 @@ public ResponseEntity unsupportedAssetOperationException(HttpServletR public ResponseEntity vocabularyImportException(HttpServletRequest request, VocabularyImportException e) { logException(e, request); - return new ResponseEntity<>( - ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), request.getRequestURI()), - HttpStatus.CONFLICT); + return new ResponseEntity<>(errorInfo(request, e), HttpStatus.CONFLICT); } @ExceptionHandler diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index 1e8eaa392..c8b922fd7 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -126,7 +126,10 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) lang); if (existingUri.isPresent() && !existingUri.get().equals(t.getUri())) { throw new VocabularyImportException( - "Vocabulary already contains a term with label '" + value + "' with a different identifier than the imported one."); + "Vocabulary already contains a term with label '" + value + "' with a different identifier than the imported one.", + "error.vocabulary.import.excel.labelWithDifferentIdentifierExists") + .addParameter("label", value) + .addParameter("existingUri", Utils.uriToString(existingUri.get())); } })) .filter(t -> termService.exists(t.getUri())).forEach(t -> { diff --git a/src/main/java/cz/cvut/kbss/termit/util/Utils.java b/src/main/java/cz/cvut/kbss/termit/util/Utils.java index da98e04c3..f8857028d 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Utils.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Utils.java @@ -49,6 +49,7 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -398,4 +399,18 @@ public static void pruneBlankTranslations(MultilingualString str) { } str.getValue().entrySet().removeIf(e -> e.getValue().isBlank()); } + + /** + * Converts the map into a string + * @return Empty string when the map is {@code null}, otherwise the String in format + * {@code {key=value, key=value}} + */ + public static String mapToString(Map map) { + if (map == null) { + return ""; + } + return map.keySet().stream() + .map(key -> key + "=" + map.get(key)) + .collect(Collectors.joining(", ", "{", "}")); + } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index b243abf5f..a65d61a48 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -85,7 +85,7 @@ private static ErrorInfo errorInfo(Message message, Throwable e) { } private static ErrorInfo errorInfo(Message message, TermItException e) { - return ErrorInfo.createWithMessageAndMessageId(e.getMessage(), e.getMessageId(), destination(message)); + return ErrorInfo.createParametrizedWithMessage(e.getMessage(), e.getMessageId(), destination(message), e.getParameters()); } @MessageExceptionHandler From 270dc1bf3094268a2e7fa96c24d01380ecf7173a Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 16 Sep 2024 08:46:09 +0200 Subject: [PATCH 143/150] [Ref] Remove ConfigParam enum, use just constants. Fix Javadoc reference issues. --- .../cvut/kbss/termit/config/WebAppConfig.java | 17 ++--- .../util/AdjustedUriTemplateProxyServlet.java | 13 +++- .../cz/cvut/kbss/termit/util/ConfigParam.java | 45 ------------ .../cvut/kbss/termit/util/Configuration.java | 1 - .../cz/cvut/kbss/termit/util/Constants.java | 2 - .../termit/util/throttle/ThrottleAspect.java | 69 +++++++++++-------- 6 files changed, 61 insertions(+), 86 deletions(-) delete mode 100644 src/main/java/cz/cvut/kbss/termit/util/ConfigParam.java diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java index eaa03f2e8..a7c4737fe 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java @@ -29,7 +29,6 @@ import cz.cvut.kbss.jsonld.jackson.serialization.SerializationConstants; import cz.cvut.kbss.termit.rest.servlet.DiagnosticsContextFilter; import cz.cvut.kbss.termit.util.AdjustedUriTemplateProxyServlet; -import cz.cvut.kbss.termit.util.ConfigParam; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.json.MultilingualStringDeserializer; import cz.cvut.kbss.termit.util.json.MultilingualStringSerializer; @@ -137,8 +136,10 @@ public ServletWrappingController sparqlEndpointController() throws Exception { final cz.cvut.kbss.termit.util.Configuration.Repository repository = config.getRepository(); p.setProperty("targetUri", repository.getUrl()); p.setProperty("log", "false"); - p.setProperty(ConfigParam.REPO_USERNAME.toString(), repository.getUsername() != null ? repository.getUsername() : ""); - p.setProperty(ConfigParam.REPO_PASSWORD.toString(), repository.getPassword() != null ? repository.getPassword() : ""); + p.setProperty(AdjustedUriTemplateProxyServlet.REPO_USERNAME_PARAM, + repository.getUsername() != null ? repository.getUsername() : ""); + p.setProperty(AdjustedUriTemplateProxyServlet.REPO_PASSWORD_PARAM, + repository.getPassword() != null ? repository.getPassword() : ""); controller.setInitParameters(p); controller.afterPropertiesSet(); return controller; @@ -149,7 +150,7 @@ public SimpleUrlHandlerMapping sparqlQueryControllerMapping() throws Exception { SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); mapping.setOrder(0); final Map urlMap = Collections.singletonMap(Constants.REST_MAPPING_PATH + "/query", - sparqlEndpointController()); + sparqlEndpointController()); mapping.setUrlMap(urlMap); return mapping; } @@ -195,10 +196,10 @@ public FilterRegistrationBean mdcFilter() { @Bean public OpenAPI customOpenAPI() { return new OpenAPI().components(new Components().addSecuritySchemes("bearer-key", - new SecurityScheme().type( - SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))) + new SecurityScheme().type( + SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) .info(new Info().title("TermIt REST API").description("TermIt REST API definition.") .version(version)); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/AdjustedUriTemplateProxyServlet.java b/src/main/java/cz/cvut/kbss/termit/util/AdjustedUriTemplateProxyServlet.java index 2355a18ea..436cf8397 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/AdjustedUriTemplateProxyServlet.java +++ b/src/main/java/cz/cvut/kbss/termit/util/AdjustedUriTemplateProxyServlet.java @@ -38,12 +38,21 @@ */ public class AdjustedUriTemplateProxyServlet extends URITemplateProxyServlet { + /** + * Configuration parameter representing username to use when connecting to the repository. + */ + public static final String REPO_USERNAME_PARAM = "repository.username"; + /** + * Configuration parameter representing password to use when connecting to the repository. + */ + public static final String REPO_PASSWORD_PARAM = "repository.password"; + @Override protected void service(HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws ServletException, IOException { - final String username = getConfigParam(ConfigParam.REPO_USERNAME.toString()); - final String password = getConfigParam(ConfigParam.REPO_PASSWORD.toString()); + final String username = getConfigParam(REPO_USERNAME_PARAM); + final String password = getConfigParam(REPO_PASSWORD_PARAM); super.service(new AuthenticatingServletRequestWrapper(servletRequest, username, password), new HttpServletResponseWrapper(servletResponse) { @Override diff --git a/src/main/java/cz/cvut/kbss/termit/util/ConfigParam.java b/src/main/java/cz/cvut/kbss/termit/util/ConfigParam.java deleted file mode 100644 index ed48145bf..000000000 --- a/src/main/java/cz/cvut/kbss/termit/util/ConfigParam.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * TermIt - * Copyright (C) 2023 Czech Technical University in Prague - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package cz.cvut.kbss.termit.util; - -/** - * Application configuration parameters, loaded from {@code application.yml} provided on classpath. - */ -public enum ConfigParam { - - /** - * Username for connecting to the application repository. - */ - REPO_USERNAME("repository.username"), - - /** - * Password for connecting to the application repository. - */ - REPO_PASSWORD("repository.password"); - - private final String parameter; - - ConfigParam(String parameter) { - this.parameter = parameter; - } - - @Override - public String toString() { - return parameter; - } -} diff --git a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java index cf609cab8..b9be4a7db 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java @@ -80,7 +80,6 @@ public class Configuration { * After how much time, should objects with completed futures be discarded. * The value must be positive ({@code > 0}). * @configurationdoc.default 1 minute - * @see ThrottleAspect#clearOldFutures() */ private Duration throttleDiscardThreshold = Duration.ofMinutes(1); diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java index 601c4703f..5d7ead6a9 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java @@ -18,12 +18,10 @@ package cz.cvut.kbss.termit.util; import cz.cvut.kbss.jopa.vocabulary.SKOS; -import cz.cvut.kbss.termit.util.throttle.ThrottleAspect; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import java.net.URI; -import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index fcf8ea14b..f86ab39d5 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -56,8 +56,8 @@ import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_SINGLETON; /** - * @see Throttle * @implNote The aspect is configured in {@code spring-aop.xml}, this uses Spring AOP instead of AspectJ. + * @see Throttle */ @Order @Scope(SCOPE_SINGLETON) @@ -80,22 +80,21 @@ public class ThrottleAspect extends LongRunningTaskScheduler { /** * The last run is updated every time a task is finished. + * * @implSpec Synchronize in the field declaration order before modification */ private final Map lastRun; /** - * Scheduled futures are returned from {@link #taskScheduler}. - * Futures are completed by execution of tasks created in {@link #createRunnableToSchedule}. - * Records about them are used for their cancellation in case of debouncing. + * Scheduled futures are returned from {@link #taskScheduler}. Futures are completed by execution of tasks created + * in {@link #createRunnableToSchedule}. Records about them are used for their cancellation in case of debouncing. * * @implSpec Synchronize in the field declaration order before modification */ private final NavigableMap> scheduledFutures; /** - * Thread safe set holding identifiers of threads that are - * currently executing a throttled task. + * Thread safe set holding identifiers of threads that are currently executing a throttled task. */ private final Set throttledThreads = ConcurrentHashMap.newKeySet(); @@ -113,6 +112,7 @@ public class ThrottleAspect extends LongRunningTaskScheduler { /** * Used for acquiring {@link #lastRun} timestamps. + * * @implNote for testing purposes */ private final Clock clock; @@ -123,8 +123,8 @@ public class ThrottleAspect extends LongRunningTaskScheduler { private final SynchronousTransactionExecutor transactionExecutor; /** - * A timestamp of the last time maps were cleaned. - * The reference might be null. + * A timestamp of the last time maps were cleaned. The reference might be null. + * * @see #clearOldFutures() */ private final AtomicReference lastClear; @@ -185,7 +185,8 @@ private static StandardEvaluationContext makeDefaultContext() { /** * @return future or null * @throws TermItException when the target method throws - * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or {@link Future} + * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or + * {@link Future} * @implNote Around advice configured in {@code spring-aop.xml} */ public @Nullable Object throttleMethodCall(@NonNull ProceedingJoinPoint joinPoint, @@ -265,8 +266,8 @@ private static StandardEvaluationContext makeDefaultContext() { if (oldScheduledFuture == null || oldThrottledFuture != future || oldScheduledFuture.isDone()) { boolean oldFutureIsDone = oldScheduledFuture == null || oldScheduledFuture.isDone(); if (oldThrottledFuture != future) { - oldThrottledFuture.then(ignored -> - schedule(identifier, pair.getFirst(), throttleExpired && oldFutureIsDone) + oldThrottledFuture.then(ignored -> schedule(identifier, pair.getFirst(), + throttleExpired && oldFutureIsDone) ); } else { schedule(identifier, pair.getFirst(), throttleExpired && oldFutureIsDone); @@ -352,11 +353,13 @@ private Pair> getFutureTask(@NonNull Proceedin } }, List.of()); } else { - throw new ThrottleAspectException("Invalid return type for " + joinPoint.getSignature() + " annotated with @Debounce, only Future or void allowed!"); + throw new ThrottleAspectException( + "Invalid return type for " + joinPoint.getSignature() + " annotated with @Debounce, only Future or void allowed!"); } final boolean withTransaction = methodSignature.getMethod() != null && methodSignature.getMethod() - .isAnnotationPresent(Transactional.class); + .isAnnotationPresent( + Transactional.class); // create a task which will be scheduled with executor final Runnable toSchedule = createRunnableToSchedule(throttledFuture, identifier, withTransaction); @@ -391,14 +394,15 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id final Long threadId = Thread.currentThread().getId(); throttledThreads.add(threadId); - LOG.trace("Running throttled task [{} left] [{} running] '{}'", countRemaining() - 1, countRunning(), identifier); + LOG.trace("Running throttled task [{} left] [{} running] '{}'", countRemaining() - 1, countRunning(), + identifier); // restore the security context SecurityContextHolder.setContext(securityContext.get()); try { // fulfill the future if (withTransaction) { - transactionExecutor.execute(()->throttledFuture.run(this::notifyTaskChanged)); + transactionExecutor.execute(() -> throttledFuture.run(this::notifyTaskChanged)); } else { throttledFuture.run(this::notifyTaskChanged); } @@ -413,7 +417,8 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id notifyTaskChanged(throttledFuture); // task done // clear the security context SecurityContextHolder.clearContext(); - LOG.trace("Finished throttled task [{} left] [{} running] '{}'", countRemaining(), countRunning() - 1, identifier); + LOG.trace("Finished throttled task [{} left] [{} running] '{}'", countRemaining(), countRunning() - 1, + identifier); clearOldFutures(); @@ -425,13 +430,16 @@ private Runnable createRunnableToSchedule(ThrottledFuture throttledFuture, Id /** * Discards futures from {@link #throttledFutures}, {@link #lastRun} and {@link #scheduledFutures} maps. - *

    Every completed future for which a {@link Configuration#throttleDiscardThreshold throttleDiscardThreshold} expired is discarded.

    + *

    + * Every completed future for which a {@link Configuration#getThrottleDiscardThreshold()} expired is discarded. + * * @see #isThresholdExpired(Identifier) */ private void clearOldFutures() { // if the last clear was performed less than a threshold ago, skip it for now Instant last = lastClear.get(); - if (last.isAfter(Instant.now(clock).minus(configuration.getThrottleThreshold()).minus(configuration.getThrottleDiscardThreshold()))) { + if (last.isAfter(Instant.now(clock).minus(configuration.getThrottleThreshold()) + .minus(configuration.getThrottleDiscardThreshold()))) { return; } if (!lastClear.compareAndSet(last, Instant.now(clock))) { @@ -444,7 +452,8 @@ private void clearOldFutures() { .stream()) .flatMap(s -> s).distinct().toList() // ensures safe modification of maps .forEach(identifier -> { - if (isThresholdExpiredByMoreThan(identifier, configuration.getThrottleDiscardThreshold())) { + if (isThresholdExpiredByMoreThan(identifier, + configuration.getThrottleDiscardThreshold())) { Optional.ofNullable(throttledFutures.get(identifier)).ifPresent(throttled -> { if (throttled.isDone()) { throttledFutures.remove(identifier); @@ -465,20 +474,22 @@ private void clearOldFutures() { /** * @param identifier of the task - * @param duration to add to the throttle threshold - * @return Whether the last time when a task with specified {@code identifier} run - * is older than ({@link Configuration#throttleThreshold throttleThreshold} + {@code duration}) + * @param duration to add to the throttle threshold + * @return Whether the last time when a task with specified {@code identifier} run is older than + * ({@link Configuration#getThrottleThreshold()} + {@code duration}) */ private boolean isThresholdExpiredByMoreThan(Identifier identifier, Duration duration) { - return lastRun.getOrDefault(identifier, Instant.MAX).isBefore(Instant.now(clock).minus(configuration.getThrottleThreshold()).minus(duration)); + return lastRun.getOrDefault(identifier, Instant.MAX) + .isBefore(Instant.now(clock).minus(configuration.getThrottleThreshold()).minus(duration)); } /** - * @return Whether the time when the identifier last run is older than the threshold, - * true when the task had never run + * @return Whether the time when the identifier last run is older than the threshold, true when the task had never + * run */ private boolean isThresholdExpired(Identifier identifier) { - return lastRun.getOrDefault(identifier, Instant.EPOCH).isBefore(Instant.now(clock).minus(configuration.getThrottleThreshold())); + return lastRun.getOrDefault(identifier, Instant.EPOCH) + .isBefore(Instant.now(clock).minus(configuration.getThrottleThreshold())); } @SuppressWarnings("unchecked") @@ -544,7 +555,8 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati if (Void.TYPE.equals(returnType) || Void.class.equals(returnType)) { return null; } - throw new ThrottleAspectException("Invalid return type for " + signature + " annotated with @Debounce, only Future or void allowed!"); + throw new ThrottleAspectException( + "Invalid return type for " + signature + " annotated with @Debounce, only Future or void allowed!"); } @@ -573,7 +585,8 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati Objects.requireNonNull(identifierList); return identifierList.stream().map(Object::toString).collect(Collectors.joining("-")); } catch (EvaluationException | ClassCastException | NullPointerException e) { - throw new ThrottleAspectException("The expression: '" + expression + "' has not been resolved to a Collection or String", e); + throw new ThrottleAspectException( + "The expression: '" + expression + "' has not been resolved to a Collection or String", e); } } From 54f84f7ec0b726e74922cff470703c87fa8e00a6 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 16 Sep 2024 08:51:07 +0200 Subject: [PATCH 144/150] [Enhancement #298] Support configuring ASCII-only identifiers. --- .../cvut/kbss/termit/util/Configuration.java | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java index b9be4a7db..fdbcfa4b1 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java @@ -18,9 +18,7 @@ package cz.cvut.kbss.termit.util; import cz.cvut.kbss.termit.model.acl.AccessLevel; -import cz.cvut.kbss.termit.util.throttle.ThrottleAspect; import jakarta.validation.Valid; -import jakarta.validation.constraints.Future; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -61,15 +59,16 @@ public class Configuration { /** * The number of threads for thread pool executing asynchronous and long-running tasks. + * * @configurationdoc.default The number of processors available to the Java virtual machine. */ @Min(1) private Integer asyncThreadCount = Runtime.getRuntime().availableProcessors(); /** - * The amount of time in which calls of throttled methods - * should be merged. - * The value must be positive ({@code > 0}). + * The amount of time in which calls of throttled methods should be merged. The value must be positive + * ({@code > 0}). + * * @configurationdoc.default 10 seconds * @see cz.cvut.kbss.termit.util.throttle.Throttle * @see cz.cvut.kbss.termit.util.throttle.ThrottleAspect @@ -77,12 +76,22 @@ public class Configuration { private Duration throttleThreshold = Duration.ofSeconds(10); /** - * After how much time, should objects with completed futures be discarded. - * The value must be positive ({@code > 0}). + * After how much time, should objects with completed futures be discarded. The value must be positive + * ({@code > 0}). + * * @configurationdoc.default 1 minute */ private Duration throttleDiscardThreshold = Duration.ofMinutes(1); + /** + * Whether to generate ASCII-only identifiers. + *

    + * By default, generated identifiers may contain accented characters (like Ä). Setting this configuration to + * {@code true} ensures all generated identifiers are ASCII-only and accented character are normalized to ASCII. + * @configurationdoc.default false + */ + private boolean asciiIdentifiers = false; + @Valid private Persistence persistence = new Persistence(); @Valid @@ -146,6 +155,14 @@ public void setAsyncThreadCount(@Min(1) Integer asyncThreadCount) { this.asyncThreadCount = asyncThreadCount; } + public boolean isAsciiIdentifiers() { + return asciiIdentifiers; + } + + public void setAsciiIdentifiers(boolean asciiIdentifiers) { + this.asciiIdentifiers = asciiIdentifiers; + } + public Persistence getPersistence() { return persistence; } From b00e9627a1a02db5bca9cd7ede2c0cdced57f2ff Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 16 Sep 2024 09:28:05 +0200 Subject: [PATCH 145/150] [Enhancement #298] Generate ASCII-only identifiers when configured to. --- .../kbss/termit/service/IdentifierResolver.java | 12 ++++++++++-- .../kbss/termit/rest/UserGroupControllerTest.java | 3 ++- .../termit/service/IdentifierResolverTest.java | 14 +++++++++++++- .../service/importer/excel/ExcelImporterTest.java | 2 +- .../repository/ResourceRepositoryServiceTest.java | 2 +- .../repository/UserRepositoryServiceTest.java | 4 ++-- .../termit/service/security/SecurityUtilsTest.java | 4 ++-- 7 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java b/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java index 232f5b7ef..fda72edb0 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java @@ -19,6 +19,7 @@ import cz.cvut.kbss.termit.exception.InvalidIdentifierException; import cz.cvut.kbss.termit.exception.TermItException; +import cz.cvut.kbss.termit.util.Configuration; import org.springframework.stereotype.Service; import java.net.URI; @@ -80,6 +81,12 @@ public class IdentifierResolver { Arrays.sort(ILLEGAL_FILENAME_CHARS); } + private final boolean asciiOnlyIdentifiers; + + public IdentifierResolver(Configuration config) { + this.asciiOnlyIdentifiers = config.isAsciiIdentifiers(); + } + /** * Normalizes the specified value which includes: *

      @@ -139,11 +146,12 @@ public URI generateIdentifier(String namespace, String... components) { if (!namespace.endsWith("/") && !namespace.endsWith("#")) { namespace += "/"; } + final String localPart = asciiOnlyIdentifiers ? normalizeToAscii(comps) : normalize(comps); try { - return URI.create(namespace + normalize(comps)); + return URI.create(namespace + localPart); } catch (IllegalArgumentException e) { throw new InvalidIdentifierException( - "Generated identifier " + namespace + normalize(comps) + " is not a valid URI.", + "Generated identifier " + namespace + localPart + " is not a valid URI.", "error.identifier.invalidCharacters"); } } diff --git a/src/test/java/cz/cvut/kbss/termit/rest/UserGroupControllerTest.java b/src/test/java/cz/cvut/kbss/termit/rest/UserGroupControllerTest.java index 50179f2c1..7c8b8d644 100644 --- a/src/test/java/cz/cvut/kbss/termit/rest/UserGroupControllerTest.java +++ b/src/test/java/cz/cvut/kbss/termit/rest/UserGroupControllerTest.java @@ -22,6 +22,7 @@ import cz.cvut.kbss.termit.model.UserGroup; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.business.UserGroupService; +import cz.cvut.kbss.termit.util.Configuration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,7 +57,7 @@ class UserGroupControllerTest extends BaseControllerTestRunner { private UserGroupService groupService; @Spy - private IdentifierResolver identifierResolver = new IdentifierResolver(); + private IdentifierResolver identifierResolver = new IdentifierResolver(new Configuration()); @InjectMocks private UserGroupController sut; diff --git a/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java b/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java index 0ba3136a4..7b2fee669 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/IdentifierResolverTest.java @@ -20,6 +20,7 @@ import cz.cvut.kbss.termit.environment.Environment; import cz.cvut.kbss.termit.environment.Generator; import cz.cvut.kbss.termit.exception.InvalidIdentifierException; +import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Vocabulary; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -42,7 +43,7 @@ class IdentifierResolverTest { @BeforeEach void setUp() { - this.sut = new IdentifierResolver(); + this.sut = new IdentifierResolver(new Configuration()); } @Test @@ -335,4 +336,15 @@ void generateIdentifierThrowsInvalidIdentifierExceptionWhenComponentsContainsUnf final String label = "label with emoji \u3000"; // Ideographic space assertThrows(InvalidIdentifierException.class, () -> sut.generateIdentifier(namespace, label)); } + + @Test + void generateIdentifierNormalizesAccentedCharactersToAsciiWhenAsciiIdentifiersAreConfigured() { + final Configuration asciiOnlyConfig = new Configuration(); + asciiOnlyConfig.setAsciiIdentifiers(true); + this.sut = new IdentifierResolver(asciiOnlyConfig); + final String namespace = "http://onto.fel.cvut.cz/ontologies/termit/test-slovnik"; + final String label = "Délka dostřiku [m]"; + final URI result = sut.generateIdentifier(namespace, label); + assertEquals(URI.create(namespace + "/delka-dostriku-m"), result); + } } diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 3254d2246..5804ca6e8 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -86,7 +86,7 @@ class ExcelImporterTest { @SuppressWarnings("unused") @Spy - private IdentifierResolver idResolver = new IdentifierResolver(); + private IdentifierResolver idResolver = new IdentifierResolver(new Configuration()); @InjectMocks private ExcelImporter sut; diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryServiceTest.java index df9fae710..6d43ec226 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryServiceTest.java @@ -62,7 +62,7 @@ class ResourceRepositoryServiceTest { private TermOccurrenceDao occurrenceDao; @Spy - private IdentifierResolver idResolver = new IdentifierResolver(); + private IdentifierResolver idResolver = new IdentifierResolver(new Configuration()); @Spy private Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); diff --git a/src/test/java/cz/cvut/kbss/termit/service/repository/UserRepositoryServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/repository/UserRepositoryServiceTest.java index aef217484..6b7126ad9 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/repository/UserRepositoryServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/repository/UserRepositoryServiceTest.java @@ -62,10 +62,10 @@ class UserRepositoryServiceTest { private UserAccountDao userAccountDao; @Spy - private IdentifierResolver identifierResolver; + private Configuration configuration = new Configuration(); @Spy - private Configuration configuration = new Configuration(); + private IdentifierResolver identifierResolver = new IdentifierResolver(configuration); @Spy private Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); diff --git a/src/test/java/cz/cvut/kbss/termit/service/security/SecurityUtilsTest.java b/src/test/java/cz/cvut/kbss/termit/service/security/SecurityUtilsTest.java index 8292ec797..62e3886ca 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/security/SecurityUtilsTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/security/SecurityUtilsTest.java @@ -69,10 +69,10 @@ class SecurityUtilsTest { private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); @Spy - private IdentifierResolver idResolver = new IdentifierResolver(); + private Configuration config = new Configuration(); @Spy - private Configuration config = new Configuration(); + private IdentifierResolver idResolver = new IdentifierResolver(config); @InjectMocks private SecurityUtils sut; From 7db4e19e85f72a4c3b9d3862084d7c34ba3e13f0 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 19 Sep 2024 08:21:02 +0200 Subject: [PATCH 146/150] [Ref #296] replace all jetbrains and spring NotNull/NonNull & Nullable with jakarta annotations --- .../config/WebSocketMessageBrokerConfig.java | 4 ++-- .../event/FileTextAnalysisFinishedEvent.java | 4 ++-- .../event/LongRunningTaskChangedEvent.java | 6 ++--- ...rmDefinitionTextAnalysisFinishedEvent.java | 4 ++-- .../event/VocabularyContentModifiedEvent.java | 4 ++-- .../termit/event/VocabularyCreatedEvent.java | 4 ++-- .../kbss/termit/event/VocabularyEvent.java | 4 ++-- .../VocabularyValidationFinishedEvent.java | 12 +++++----- .../event/VocabularyWillBeRemovedEvent.java | 4 ++-- .../termit/exception/TermItException.java | 10 ++++---- .../kbss/termit/persistence/dao/BaseDao.java | 4 ++-- .../termit/persistence/dao/SearchDao.java | 10 ++++---- .../dao/acl/AccessControlListDao.java | 4 ++-- .../dao/lucene/LuceneSearchDao.java | 6 ++--- .../validation/ResultCachingValidator.java | 14 +++++------ .../persistence/validation/Validator.java | 8 +++---- .../VocabularyContentValidator.java | 6 ++--- .../cvut/kbss/termit/security/JwtUtils.java | 4 ++-- .../WebSocketJwtAuthorizationInterceptor.java | 6 ++--- .../kbss/termit/service/MessageFormatter.java | 6 ++--- .../business/AccessControlListService.java | 4 ++-- .../service/business/ResourceService.java | 4 ++-- .../service/business/SearchService.java | 6 ++--- .../termit/service/business/TermService.java | 4 ++-- .../termit/service/business/UserService.java | 7 +++--- .../service/business/VocabularyService.java | 4 ++-- .../AccessControlListCacheKeyGenerator.java | 7 +++--- .../service/changetracking/ChangeTracker.java | 4 ++-- .../html/TextPositionSelectorGenerator.java | 3 --- .../service/importer/VocabularyImporters.java | 4 ++-- .../service/importer/excel/ExcelImporter.java | 4 ++-- .../kbss/termit/service/jmx/AppAdminBean.java | 4 ++-- .../language/TermStateLanguageService.java | 4 ++-- .../service/language/UfoTermTypesService.java | 4 ++-- .../repository/BaseRepositoryService.java | 18 +++++++------- .../RepositoryAccessControlListService.java | 4 ++-- .../repository/ResourceRepositoryService.java | 6 ++--- .../repository/TermRepositoryService.java | 14 +++++------ .../UserGroupRepositoryService.java | 4 ++-- .../repository/UserRepositoryService.java | 8 +++---- .../VocabularyRepositoryService.java | 12 +++++----- .../SearchAuthorizationService.java | 6 ++--- .../cvut/kbss/termit/util/ExceptionUtils.java | 4 ++-- .../java/cz/cvut/kbss/termit/util/Pair.java | 4 ++-- .../util/longrunning/LongRunningTask.java | 8 +++---- .../longrunning/LongRunningTaskScheduler.java | 4 ++-- .../longrunning/LongRunningTaskStatus.java | 12 +++++----- .../longrunning/LongRunningTasksRegistry.java | 8 +++---- .../termit/util/throttle/CacheableFuture.java | 2 +- .../SynchronousTransactionExecutor.java | 4 ++-- .../kbss/termit/util/throttle/Throttle.java | 8 +++---- .../termit/util/throttle/ThrottleAspect.java | 24 +++++++++---------- .../termit/util/throttle/ThrottledFuture.java | 18 +++++++------- .../websocket/BaseWebSocketController.java | 12 +++++----- .../LongRunningTasksWebSocketController.java | 4 ++-- .../websocket/VocabularySocketController.java | 10 ++++---- .../handler/StompExceptionHandler.java | 6 ++--- .../kbss/termit/environment/Transaction.java | 6 ++--- .../config/TestWebSocketConfig.java | 4 ++-- .../service/business/AssetServiceTest.java | 4 ++-- .../business/VocabularyServiceTest.java | 4 ++-- .../termit/util/throttle/MockedThrottle.java | 12 +++++----- .../util/throttle/ScheduledFutureTask.java | 10 ++++---- .../BaseWebSocketIntegrationTestRunner.java | 10 ++++---- .../util/CachingChannelInterceptor.java | 4 ++-- ...nValueCollectingSimpMessagingTemplate.java | 4 ++-- 66 files changed, 220 insertions(+), 225 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java index 3f5cdb08d..483c4d9d1 100644 --- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java +++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java @@ -4,12 +4,12 @@ import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; +import jakarta.annotation.Nonnull; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; -import org.springframework.lang.NonNull; import org.springframework.messaging.Message; import org.springframework.messaging.converter.MappingJackson2MessageConverter; import org.springframework.messaging.converter.MessageConverter; @@ -86,7 +86,7 @@ public void addArgumentResolvers(List argumentRes * @see Spring security source */ @Override - public void configureClientInboundChannel(@NonNull ChannelRegistration registration) { + public void configureClientInboundChannel(@Nonnull ChannelRegistration registration) { AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(messageAuthorizationManager); interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(context)); registration.interceptors(webSocketJwtAuthorizationInterceptor, new SecurityContextChannelInterceptor(), interceptor); diff --git a/src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java index d8d7caa40..020b21eb3 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.event; import cz.cvut.kbss.termit.model.resource.File; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.net.URI; @@ -12,7 +12,7 @@ public class FileTextAnalysisFinishedEvent extends VocabularyEvent { private final URI fileUri; - public FileTextAnalysisFinishedEvent(Object source, @NonNull File file) { + public FileTextAnalysisFinishedEvent(Object source, @Nonnull File file) { super(source, file.getDocument().getVocabulary()); this.fileUri = file.getUri(); } diff --git a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java index fd3cf7af1..b71cd4822 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java @@ -1,8 +1,8 @@ package cz.cvut.kbss.termit.event; import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskStatus; +import jakarta.annotation.Nonnull; import org.springframework.context.ApplicationEvent; -import org.springframework.lang.NonNull; /** * Indicates a status change of a long-running task. @@ -11,12 +11,12 @@ public class LongRunningTaskChangedEvent extends ApplicationEvent { private final LongRunningTaskStatus status; - public LongRunningTaskChangedEvent(@NonNull Object source, final @NonNull LongRunningTaskStatus status) { + public LongRunningTaskChangedEvent(@Nonnull Object source, final @Nonnull LongRunningTaskStatus status) { super(source); this.status = status; } - public @NonNull LongRunningTaskStatus getStatus() { + public @Nonnull LongRunningTaskStatus getStatus() { return status; } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java index 748d7a075..b9fe53f01 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.event; import cz.cvut.kbss.termit.model.AbstractTerm; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.net.URI; @@ -11,7 +11,7 @@ public class TermDefinitionTextAnalysisFinishedEvent extends VocabularyEvent { private final URI termUri; - public TermDefinitionTextAnalysisFinishedEvent(@NonNull Object source, @NonNull AbstractTerm term) { + public TermDefinitionTextAnalysisFinishedEvent(@Nonnull Object source, @Nonnull AbstractTerm term) { super(source, term.getVocabulary()); this.termUri = term.getUri(); } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java index 324c1d45c..2fa16640e 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java @@ -17,7 +17,7 @@ */ package cz.cvut.kbss.termit.event; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.net.URI; @@ -28,7 +28,7 @@ */ public class VocabularyContentModifiedEvent extends VocabularyEvent { - public VocabularyContentModifiedEvent(@NonNull Object source, @NonNull URI vocabularyIri) { + public VocabularyContentModifiedEvent(@Nonnull Object source, @Nonnull URI vocabularyIri) { super(source, vocabularyIri); } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java index 704169105..81de28b1c 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java @@ -17,7 +17,7 @@ */ package cz.cvut.kbss.termit.event; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.net.URI; @@ -26,7 +26,7 @@ */ public class VocabularyCreatedEvent extends VocabularyEvent { - public VocabularyCreatedEvent(@NonNull Object source, @NonNull URI vocabularyIri) { + public VocabularyCreatedEvent(@Nonnull Object source, @Nonnull URI vocabularyIri) { super(source, vocabularyIri); } } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java index 133afe2f5..ed8cd2203 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.event; +import jakarta.annotation.Nonnull; import org.springframework.context.ApplicationEvent; -import org.springframework.lang.NonNull; import java.net.URI; import java.util.Objects; @@ -12,7 +12,7 @@ public abstract class VocabularyEvent extends ApplicationEvent { protected final URI vocabularyIri; - protected VocabularyEvent(@NonNull Object source, @NonNull URI vocabularyIri) { + protected VocabularyEvent(@Nonnull Object source, @Nonnull URI vocabularyIri) { super(source); Objects.requireNonNull(vocabularyIri); this.vocabularyIri = vocabularyIri; diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java index a5af0bbe8..ff3ab671d 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.event; import cz.cvut.kbss.termit.model.validation.ValidationResult; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.net.URI; import java.util.ArrayList; @@ -28,21 +28,21 @@ public class VocabularyValidationFinishedEvent extends VocabularyEvent { * @param vocabularyIris IRI of the vocabulary on which the validation was triggered. * @param validationResults results of the validation */ - public VocabularyValidationFinishedEvent(@NonNull Object source, @NonNull URI originVocabularyIri, - @NonNull Collection vocabularyIris, - @NonNull List validationResults) { + public VocabularyValidationFinishedEvent(@Nonnull Object source, @Nonnull URI originVocabularyIri, + @Nonnull Collection vocabularyIris, + @Nonnull List validationResults) { super(source, originVocabularyIri); // defensive copy this.vocabularyIris = new ArrayList<>(vocabularyIris); this.validationResults = new ArrayList<>(validationResults); } - @NonNull + @Nonnull public List getVocabularyIris() { return Collections.unmodifiableList(vocabularyIris); } - @NonNull + @Nonnull public List getValidationResults() { return Collections.unmodifiableList(validationResults); } diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java index 0e0b6503a..8af856290 100644 --- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java +++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.event; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.net.URI; @@ -9,7 +9,7 @@ */ public class VocabularyWillBeRemovedEvent extends VocabularyEvent { - public VocabularyWillBeRemovedEvent(@NonNull Object source, @NonNull URI vocabularyIri) { + public VocabularyWillBeRemovedEvent(@Nonnull Object source, @Nonnull URI vocabularyIri) { super(source, vocabularyIri); } } diff --git a/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java b/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java index 526ae80bc..6ff81e4ab 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/TermItException.java @@ -18,8 +18,8 @@ package cz.cvut.kbss.termit.exception; import cz.cvut.kbss.termit.util.Utils; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.util.HashMap; import java.util.Map; @@ -72,18 +72,18 @@ public TermItException(String message, Throwable cause, String messageId) { this.messageId = messageId; } - public TermItException(String message, Throwable cause, String messageId, @NonNull Map parameters) { + public TermItException(String message, Throwable cause, String messageId, @Nonnull Map parameters) { super(message, cause); this.messageId = messageId; addParameters(parameters); } - public TermItException addParameters(@NonNull Map parameters) { + public TermItException addParameters(@Nonnull Map parameters) { this.parameters.putAll(parameters); return this; } - public TermItException addParameter(@NonNull String key, @NonNull String value) { + public TermItException addParameter(@Nonnull String key, @Nonnull String value) { this.parameters.put(key, value); return this; } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/BaseDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/BaseDao.java index a4e495cc1..532f07c06 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/BaseDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/BaseDao.java @@ -25,7 +25,7 @@ import cz.cvut.kbss.termit.exception.PersistenceException; import cz.cvut.kbss.termit.model.util.EntityToOwlClassMapper; import cz.cvut.kbss.termit.model.util.HasIdentifier; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; @@ -159,7 +159,7 @@ protected Descriptor getDescriptor() { } @Override - public void setApplicationEventPublisher(@NotNull ApplicationEventPublisher eventPublisher) { + public void setApplicationEventPublisher(@Nonnull ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } } diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/SearchDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/SearchDao.java index 8b6c0bffa..12d9e0d5e 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/SearchDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/SearchDao.java @@ -26,13 +26,13 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.Vocabulary; +import jakarta.annotation.Nonnull; import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Pageable; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Repository; import java.net.URI; @@ -79,7 +79,7 @@ private void loadQueries() { * @return List of matching results * @see #fullTextSearchIncludingSnapshots(String) */ - public List fullTextSearch(@NonNull String searchString) { + public List fullTextSearch(@Nonnull String searchString) { Objects.requireNonNull(searchString); if (searchString.isBlank()) { return Collections.emptyList(); @@ -102,7 +102,7 @@ public List fullTextSearch(@NonNull String searchString) { * @return List of matching results * @see #fullTextSearchIncludingSnapshots(String) */ - public List fullTextSearchIncludingSnapshots(@NonNull String searchString) { + public List fullTextSearchIncludingSnapshots(@Nonnull String searchString) { Objects.requireNonNull(searchString); if (searchString.isBlank()) { return Collections.emptyList(); @@ -136,8 +136,8 @@ protected String queryIncludingSnapshots() { * @param pageSpec Specification of the page of results to return * @return List of matching terms, ordered by label */ - public List facetedTermSearch(@NonNull Collection searchParams, - @NonNull Pageable pageSpec) { + public List facetedTermSearch(@Nonnull Collection searchParams, + @Nonnull Pageable pageSpec) { Objects.requireNonNull(searchParams); Objects.requireNonNull(pageSpec); LOG.trace("Running faceted term search for search parameters: {}", searchParams); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/acl/AccessControlListDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/acl/AccessControlListDao.java index 5d42885e6..dda7e109e 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/acl/AccessControlListDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/acl/AccessControlListDao.java @@ -29,7 +29,7 @@ import cz.cvut.kbss.termit.persistence.context.DescriptorFactory; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.Vocabulary; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import org.springframework.stereotype.Repository; import java.net.URI; @@ -170,7 +170,7 @@ public Optional resolveSubjectOf(AccessControlList acl) { * @param agent Agent whose access is examined * @return List of matching assets */ - public List> findAssetsByAgentWithSecurityAccess(@NonNull AccessControlAgent agent) { + public List> findAssetsByAgentWithSecurityAccess(@Nonnull AccessControlAgent agent) { Objects.requireNonNull(agent); return em.createNativeQuery("SELECT ?a WHERE { " + "?a a ?type ; " + diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/lucene/LuceneSearchDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/lucene/LuceneSearchDao.java index efc9f7258..9f90cd42c 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/lucene/LuceneSearchDao.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/lucene/LuceneSearchDao.java @@ -22,10 +22,10 @@ import cz.cvut.kbss.termit.persistence.dao.SearchDao; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Vocabulary; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Repository; import java.net.URI; @@ -53,7 +53,7 @@ public LuceneSearchDao(EntityManager em, Configuration config) { } @Override - public List fullTextSearch(@NonNull String searchString) { + public List fullTextSearch(@Nonnull String searchString) { Objects.requireNonNull(searchString); if (searchString.isBlank()) { return Collections.emptyList(); @@ -87,7 +87,7 @@ private static String splitExactMatch(String searchString) { } @Override - public List fullTextSearchIncludingSnapshots(@NonNull String searchString) { + public List fullTextSearchIncludingSnapshots(@Nonnull String searchString) { Objects.requireNonNull(searchString); if (searchString.isBlank()) { return Collections.emptyList(); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java index 1d6cfe406..29f97f735 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java @@ -25,13 +25,13 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.util.throttle.Throttle; import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Lookup; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; import org.springframework.context.event.EventListener; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -64,11 +64,11 @@ public class ResultCachingValidator implements VocabularyContentValidator { /** * @return true when the cache contents are dirty and should be refreshed; false otherwise. */ - public boolean isNotDirty(@NonNull URI originVocabularyIri) { + public boolean isNotDirty(@Nonnull URI originVocabularyIri) { return vocabularyClosure.containsKey(originVocabularyIri); } - private Optional> getCached(@NonNull URI originVocabularyIri) { + private Optional> getCached(@Nonnull URI originVocabularyIri) { synchronized (validationCache) { return Optional.ofNullable(validationCache.get(originVocabularyIri)); } @@ -77,8 +77,8 @@ private Optional> getCached(@NonNull URI originVoca @Throttle(value = "{#originVocabularyIri}", name="vocabularyValidation") @Transactional @Override - @NonNull - public ThrottledFuture> validate(@NonNull URI originVocabularyIri, @NonNull Collection vocabularyIris) { + @Nonnull + public ThrottledFuture> validate(@Nonnull URI originVocabularyIri, @Nonnull Collection vocabularyIris) { final Set iris = Set.copyOf(vocabularyIris); if (iris.isEmpty()) { @@ -94,8 +94,8 @@ public ThrottledFuture> validate(@NonNull URI origi return ThrottledFuture.of(() -> runValidation(originVocabularyIri, iris)).setCachedResult(cached.orElse(null)); } - @NonNull - private Collection runValidation(@NonNull URI originVocabularyIri, @NonNull final Set iris) { + @Nonnull + private Collection runValidation(@Nonnull URI originVocabularyIri, @Nonnull final Set iris) { Optional> cached = getCached(originVocabularyIri); if (isNotDirty(originVocabularyIri) && cached.isPresent()) { return cached.get(); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java index b01ac7dbf..c4435a832 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java @@ -29,6 +29,7 @@ import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.throttle.Throttle; import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; +import jakarta.annotation.Nonnull; import org.apache.jena.rdf.model.Literal; import org.apache.jena.rdf.model.Model; import org.apache.jena.rdf.model.ModelFactory; @@ -45,7 +46,6 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -146,8 +146,8 @@ private void loadOverrideRules(Model validationModel, String language) throws IO @Throttle(value = "{#originVocabularyIri}", name = "vocabularyValidation") @Transactional(readOnly = true) @Override - @NonNull - public ThrottledFuture> validate(final @NonNull URI originVocabularyIri, final @NonNull Collection vocabularyIris) { + @Nonnull + public ThrottledFuture> validate(final @Nonnull URI originVocabularyIri, final @Nonnull Collection vocabularyIris) { if (vocabularyIris.isEmpty()) { return ThrottledFuture.done(List.of()); } @@ -159,7 +159,7 @@ public ThrottledFuture> validate(final @NonNull URI }); } - protected synchronized List runValidation(@NonNull Collection vocabularyIris) { + protected synchronized List runValidation(@Nonnull Collection vocabularyIris) { LOG.debug("Validating {}", vocabularyIris); try { LOG.trace("Constructing model from RDF4J repository..."); diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java index 54ffa94ae..13d86ace8 100644 --- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java +++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java @@ -19,7 +19,7 @@ import cz.cvut.kbss.termit.model.validation.ValidationResult; import cz.cvut.kbss.termit.util.throttle.ThrottledFuture; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.net.URI; import java.util.Collection; @@ -38,6 +38,6 @@ public interface VocabularyContentValidator { * @param vocabularyIris Vocabulary identifiers (including {@code originVocabularyIri} * @return List of violations of validation rules. Empty list if there are not violations */ - @NonNull - ThrottledFuture> validate(@NonNull URI originVocabularyIri, @NonNull Collection vocabularyIris); + @Nonnull + ThrottledFuture> validate(@Nonnull URI originVocabularyIri, @Nonnull Collection vocabularyIris); } diff --git a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java index e10d16462..2951fe910 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java @@ -37,7 +37,7 @@ import io.jsonwebtoken.jackson.io.JacksonSerializer; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SecurityException; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.core.GrantedAuthority; @@ -125,7 +125,7 @@ public TermItUserDetails extractUserInfo(String token) { } } - public TermItUserDetails extractUserInfo(final @NotNull Claims claims) { + public TermItUserDetails extractUserInfo(final @Nonnull Claims claims) { Objects.requireNonNull(claims); try { verifyAttributePresence(claims); diff --git a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java index eda4786a7..8cda5bc9a 100644 --- a/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java +++ b/src/main/java/cz/cvut/kbss/termit/security/WebSocketJwtAuthorizationInterceptor.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.security; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.springframework.http.HttpHeaders; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; @@ -36,7 +36,7 @@ public WebSocketJwtAuthorizationInterceptor(JwtAuthenticationProvider jwtAuthent } @Override - public Message preSend(@NotNull Message message, @NotNull MessageChannel channel) { + public Message preSend(@Nonnull Message message, @Nonnull MessageChannel channel) { StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); if (headerAccessor != null && StompCommand.CONNECT.equals(headerAccessor.getCommand()) && headerAccessor.isMutable()) { final String authHeader = headerAccessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); @@ -58,7 +58,7 @@ public Message preSend(@NotNull Message message, @NotNull MessageChannel c * And for example {@link org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider} * also supports only {@code Bearer} tokens. */ - protected void process(StompHeaderAccessor stompHeaderAccessor, final @NotNull String authHeader) { + protected void process(StompHeaderAccessor stompHeaderAccessor, final @Nonnull String authHeader) { if (!StringUtils.startsWithIgnoreCase(authHeader, SecurityConstants.JWT_TOKEN_PREFIX)) { throw new InvalidBearerTokenException("Invalid Bearer token in authorization header"); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/MessageFormatter.java b/src/main/java/cz/cvut/kbss/termit/service/MessageFormatter.java index 10b147a60..5170de08b 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/MessageFormatter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/MessageFormatter.java @@ -1,8 +1,8 @@ package cz.cvut.kbss.termit.service; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.lang.NonNull; import java.text.MessageFormat; import java.util.Locale; @@ -21,7 +21,7 @@ public class MessageFormatter { private final ResourceBundle messages; - public MessageFormatter(@NonNull String lang) { + public MessageFormatter(@Nonnull String lang) { Objects.requireNonNull(lang); final Locale locale = new Locale(lang); this.messages = ResourceBundle.getBundle("i18n/messages", locale); @@ -35,7 +35,7 @@ public MessageFormatter(@NonNull String lang) { * @param params Parameters to substitute into the message string * @return Formatted message */ - public String formatMessage(@NonNull String messageId, Object... params) { + public String formatMessage(@Nonnull String messageId, Object... params) { Objects.requireNonNull(messageId); try { final MessageFormat formatter = new MessageFormat(messages.getString(messageId)); diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/AccessControlListService.java b/src/main/java/cz/cvut/kbss/termit/service/business/AccessControlListService.java index 783b54a33..4b6cdc889 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/AccessControlListService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/AccessControlListService.java @@ -23,7 +23,7 @@ import cz.cvut.kbss.termit.model.acl.AccessControlList; import cz.cvut.kbss.termit.model.acl.AccessControlRecord; import cz.cvut.kbss.termit.model.util.HasIdentifier; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.net.URI; import java.util.List; @@ -137,5 +137,5 @@ public interface AccessControlListService { * @param agent Agent whose access to examine * @return List of matching assets */ - List> findAssetsByAgentWithSecurityAccess(@NonNull AccessControlAgent agent); + List> findAssetsByAgentWithSecurityAccess(@Nonnull AccessControlAgent agent); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java index f3d7a9cc7..f8d8f87a3 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java @@ -38,7 +38,7 @@ import cz.cvut.kbss.termit.service.repository.ChangeRecordService; import cz.cvut.kbss.termit.service.repository.ResourceRepositoryService; import cz.cvut.kbss.termit.util.TypeAwareResource; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -374,7 +374,7 @@ public List getChanges(Resource asset) { } @Override - public void setApplicationEventPublisher(@NotNull ApplicationEventPublisher eventPublisher) { + public void setApplicationEventPublisher(@Nonnull ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/SearchService.java b/src/main/java/cz/cvut/kbss/termit/service/business/SearchService.java index f8f787591..72efc6b3a 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/SearchService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/SearchService.java @@ -22,9 +22,9 @@ import cz.cvut.kbss.termit.dto.search.FullTextSearchResult; import cz.cvut.kbss.termit.dto.search.SearchParam; import cz.cvut.kbss.termit.persistence.dao.SearchDao; +import jakarta.annotation.Nonnull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Pageable; -import org.springframework.lang.NonNull; import org.springframework.security.access.prepost.PostFilter; import org.springframework.stereotype.Service; @@ -87,8 +87,8 @@ public List fullTextSearchOfTerms(String searchString, Set * @return List of matching terms, sorted by label */ @PostFilter("@searchAuthorizationService.canRead(filterObject)") - public List facetedTermSearch(@NonNull Collection searchParams, - @NonNull Pageable pageSpec) { + public List facetedTermSearch(@Nonnull Collection searchParams, + @Nonnull Pageable pageSpec) { Objects.requireNonNull(searchParams); Objects.requireNonNull(pageSpec); searchParams.forEach(SearchParam::validate); diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java index db3bb6564..35b92fa57 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java @@ -43,11 +43,11 @@ import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.throttle.Throttle; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Pageable; -import org.springframework.lang.NonNull; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; @@ -426,7 +426,7 @@ public Term update(Term term) { * @param term Term to remove */ @PreAuthorize("@termAuthorizationService.canRemove(#term)") - public void remove(@NonNull Term term) { + public void remove(@Nonnull Term term) { Objects.requireNonNull(term); repositoryService.remove(term); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/UserService.java b/src/main/java/cz/cvut/kbss/termit/service/business/UserService.java index a5022f675..296c1dc5e 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/UserService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/UserService.java @@ -38,12 +38,11 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.Vocabulary; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.event.EventListener; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -222,7 +221,7 @@ public void updateCurrent(UserUpdateDto update) { repositoryService.update(update.asUserAccount()); } - @NotNull + @Nonnull private UserAccount getCurrentUser(UserUpdateDto update) { UserAccount currentUser = securityUtils.getCurrentUser(); @@ -344,7 +343,7 @@ public boolean exists(String username) { * @param user User whose access to check * @return List of RDFS resources representing the managed assets */ - public List getManagedAssets(@NonNull UserAccount user) { + public List getManagedAssets(@Nonnull UserAccount user) { Objects.requireNonNull(user); return aclService.findAssetsByAgentWithSecurityAccess(user.toUser()).stream() .map(dtoMapper::assetToRdfsResource).collect(Collectors.toList()); diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java index 1d20cf5b2..6d2d409ae 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java @@ -48,7 +48,7 @@ import cz.cvut.kbss.termit.util.TypeAwareResource; import cz.cvut.kbss.termit.util.throttle.CacheableFuture; import cz.cvut.kbss.termit.util.throttle.Throttle; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; @@ -497,7 +497,7 @@ public AccessLevel getAccessLevel(Vocabulary vocabulary) { } @Override - public void setApplicationEventPublisher(@NotNull ApplicationEventPublisher eventPublisher) { + public void setApplicationEventPublisher(@Nonnull ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/cache/AccessControlListCacheKeyGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/cache/AccessControlListCacheKeyGenerator.java index 9dbdad86f..c18efb95b 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/cache/AccessControlListCacheKeyGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/cache/AccessControlListCacheKeyGenerator.java @@ -20,9 +20,8 @@ import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.model.acl.AccessControlList; import cz.cvut.kbss.termit.persistence.dao.acl.AccessControlListDao; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.springframework.cache.interceptor.KeyGenerator; -import org.springframework.lang.NonNull; import java.lang.reflect.Method; @@ -36,12 +35,12 @@ public class AccessControlListCacheKeyGenerator implements KeyGenerator { private final AccessControlListDao aclDao; - public AccessControlListCacheKeyGenerator(@NonNull AccessControlListDao aclDao) { + public AccessControlListCacheKeyGenerator(@Nonnull AccessControlListDao aclDao) { this.aclDao = aclDao; } @Override - public @NotNull Object generate(@NotNull Object target, @NotNull Method method, Object @NotNull ... params) { + public @Nonnull Object generate(@Nonnull Object target, @Nonnull Method method, Object... params) { assert params.length == 1 && params[0] instanceof AccessControlList; final AccessControlList acl = (AccessControlList) params[0]; return aclDao.resolveSubjectOf(acl) diff --git a/src/main/java/cz/cvut/kbss/termit/service/changetracking/ChangeTracker.java b/src/main/java/cz/cvut/kbss/termit/service/changetracking/ChangeTracker.java index de0de54e0..b9497ab94 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/changetracking/ChangeTracker.java +++ b/src/main/java/cz/cvut/kbss/termit/service/changetracking/ChangeTracker.java @@ -29,11 +29,11 @@ import cz.cvut.kbss.termit.persistence.dao.changetracking.ChangeTrackingHelperDao; import cz.cvut.kbss.termit.service.security.SecurityUtils; import cz.cvut.kbss.termit.util.Utils; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.event.EventListener; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -102,7 +102,7 @@ public void onAssetUpdateEvent(AssetUpdateEvent event) { */ @Transactional @EventListener - public void onAssetPersistEvent(@NonNull AssetPersistEvent event) { + public void onAssetPersistEvent(@Nonnull AssetPersistEvent event) { final Asset added = event.getAsset(); if (added instanceof File) { LOG.trace("Skipping recording of creation of file {}.", added); diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java index 767e06676..8f3eda5cb 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java @@ -22,11 +22,8 @@ import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; import org.jsoup.select.Elements; -import org.jsoup.select.NodeTraversor; -import org.jsoup.select.NodeVisitor; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; /** * Generates a {@link TextPositionSelector} for the specified elements. diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java index d2782a9f1..5eb792580 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/VocabularyImporters.java @@ -4,8 +4,8 @@ import cz.cvut.kbss.termit.model.Vocabulary; import cz.cvut.kbss.termit.persistence.dao.skos.SKOSImporter; import cz.cvut.kbss.termit.service.importer.excel.ExcelImporter; +import jakarta.annotation.Nonnull; import org.springframework.context.ApplicationContext; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; /** @@ -21,7 +21,7 @@ public VocabularyImporters(ApplicationContext appContext) { } @Override - public Vocabulary importVocabulary(@NonNull ImportConfiguration config, @NonNull ImportInput data) { + public Vocabulary importVocabulary(@Nonnull ImportConfiguration config, @Nonnull ImportInput data) { if (SKOSImporter.supportsMediaType(data.mediaType())) { return getSkosImporter().importVocabulary(config, data); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index c8b922fd7..1a8ed5f68 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -17,6 +17,7 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.Utils; +import jakarta.annotation.Nonnull; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook; @@ -24,7 +25,6 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import java.io.IOException; @@ -221,7 +221,7 @@ private URI resolveTermIdentifier(Vocabulary vocabulary, Term term) { * @param mediaType Media type to check * @return {@code true} when media type is supported, {@code false} otherwise */ - public static boolean supportsMediaType(@NonNull String mediaType) { + public static boolean supportsMediaType(@Nonnull String mediaType) { return Constants.MediaType.EXCEL.equals(mediaType) || XLS_MEDIA_TYPE.equals(mediaType); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java b/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java index c6095f424..d81a2ec6c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java +++ b/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java @@ -23,7 +23,7 @@ import cz.cvut.kbss.termit.service.mail.Message; import cz.cvut.kbss.termit.service.mail.Postman; import cz.cvut.kbss.termit.util.Configuration; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -75,7 +75,7 @@ public void sendTestEmail(String address) { } @Override - public @NotNull ObjectName getObjectName() throws MalformedObjectNameException { + public @Nonnull ObjectName getObjectName() throws MalformedObjectNameException { return new ObjectName("bean:name=" + beanName); } diff --git a/src/main/java/cz/cvut/kbss/termit/service/language/TermStateLanguageService.java b/src/main/java/cz/cvut/kbss/termit/service/language/TermStateLanguageService.java index d6906c970..e5123c00f 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/language/TermStateLanguageService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/language/TermStateLanguageService.java @@ -21,6 +21,7 @@ import cz.cvut.kbss.termit.exception.LanguageRetrievalException; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.Vocabulary; +import jakarta.annotation.Nonnull; import org.eclipse.rdf4j.model.Model; import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.impl.SimpleValueFactory; @@ -30,7 +31,6 @@ import org.eclipse.rdf4j.rio.RDFParseException; import org.eclipse.rdf4j.rio.Rio; import org.eclipse.rdf4j.rio.UnsupportedRDFormatException; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @@ -69,7 +69,7 @@ public List getTermStates() { return cache.stream().map(RdfsResource::new).collect(Collectors.toList()); } - @NotNull + @Nonnull private List loadTermStates() { try { final ValueFactory vf = SimpleValueFactory.getInstance(); diff --git a/src/main/java/cz/cvut/kbss/termit/service/language/UfoTermTypesService.java b/src/main/java/cz/cvut/kbss/termit/service/language/UfoTermTypesService.java index 88c0e8b03..81bd52bf8 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/language/UfoTermTypesService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/language/UfoTermTypesService.java @@ -22,6 +22,7 @@ import cz.cvut.kbss.termit.exception.LanguageRetrievalException; import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.util.Utils; +import jakarta.annotation.Nonnull; import org.eclipse.rdf4j.model.Model; import org.eclipse.rdf4j.model.vocabulary.RDF; import org.eclipse.rdf4j.model.vocabulary.SKOS; @@ -29,7 +30,6 @@ import org.eclipse.rdf4j.rio.RDFParseException; import org.eclipse.rdf4j.rio.Rio; import org.eclipse.rdf4j.rio.UnsupportedRDFormatException; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.io.Resource; @@ -76,7 +76,7 @@ public List getTypes() { }).collect(Collectors.toList()); } - @NotNull + @Nonnull private List loadTermTypes() { try { final Model model = Rio.parse(languageTtlUrl.getInputStream(), RDFFormat.TURTLE); diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java index be5bcceae..413235e4f 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java @@ -24,8 +24,8 @@ import cz.cvut.kbss.termit.model.util.HasIdentifier; import cz.cvut.kbss.termit.persistence.dao.GenericDao; import cz.cvut.kbss.termit.validation.ValidationResult; +import jakarta.annotation.Nonnull; import jakarta.validation.Validator; -import org.springframework.lang.NonNull; import org.springframework.transaction.annotation.Transactional; import java.net.URI; @@ -146,7 +146,7 @@ private Class resolveGenericType() { * * @param instance The loaded instance, not {@code null} */ - protected T postLoad(@NonNull T instance) { + protected T postLoad(@Nonnull T instance) { // Do nothing return instance; } @@ -157,7 +157,7 @@ protected T postLoad(@NonNull T instance) { * @param instance The instance to persist */ @Transactional - public void persist(@NonNull T instance) { + public void persist(@Nonnull T instance) { Objects.requireNonNull(instance); prePersist(instance); getPrimaryDao().persist(instance); @@ -171,7 +171,7 @@ public void persist(@NonNull T instance) { * * @param instance The instance to be persisted, not {@code null} */ - protected void prePersist(@NonNull T instance) { + protected void prePersist(@Nonnull T instance) { validate(instance); } @@ -180,7 +180,7 @@ protected void prePersist(@NonNull T instance) { * * @param instance The persisted instance, not {@code null} */ - protected void postPersist(@NonNull T instance) { + protected void postPersist(@Nonnull T instance) { // Do nothing } @@ -207,7 +207,7 @@ public T update(T instance) { * * @param instance The instance to be updated, not {@code null} */ - protected void preUpdate(@NonNull T instance) { + protected void preUpdate(@Nonnull T instance) { if (!exists(instance.getUri())) { throw NotFoundException.create(instance.getClass().getSimpleName(), instance.getUri()); } @@ -219,7 +219,7 @@ protected void preUpdate(@NonNull T instance) { * * @param instance The updated instance which will be returned by {@link #update(HasIdentifier)}, not {@code null} */ - protected void postUpdate(@NonNull T instance) { + protected void postUpdate(@Nonnull T instance) { // Do nothing } @@ -243,7 +243,7 @@ public void remove(T instance) { * * @param instance The instance to be removed, not {@code null} */ - protected void preRemove(@NonNull T instance) { + protected void preRemove(@Nonnull T instance) { // Do nothing } @@ -254,7 +254,7 @@ protected void preRemove(@NonNull T instance) { * * @param instance The removed instance, not {@code null} */ - protected void postRemove(@NonNull T instance) { + protected void postRemove(@Nonnull T instance) { // Do nothing } diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListService.java index 6598bfaaa..f2844bc7d 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/RepositoryAccessControlListService.java @@ -35,13 +35,13 @@ import cz.cvut.kbss.termit.service.security.SecurityUtils; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -219,7 +219,7 @@ public void updateRecordAccessLevel(AccessControlList acl, AccessControlRecord> findAssetsByAgentWithSecurityAccess(@NonNull AccessControlAgent agent) { + public List> findAssetsByAgentWithSecurityAccess(@Nonnull AccessControlAgent agent) { return dao.findAssetsByAgentWithSecurityAccess(agent); } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryService.java index 5f0b9bb61..9f7b256f9 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/ResourceRepositoryService.java @@ -27,8 +27,8 @@ import cz.cvut.kbss.termit.persistence.dao.TermOccurrenceDao; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; +import jakarta.annotation.Nonnull; import jakarta.validation.Validator; -import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -73,7 +73,7 @@ protected Resource mapToDto(Resource entity) { } @Override - protected void prePersist(@NotNull Resource instance) { + protected void prePersist(@Nonnull Resource instance) { super.prePersist(instance); if (instance.getUri() == null) { instance.setUri(idResolver.generateIdentifier(cfgNamespace.getResource(), instance.getLabel())); @@ -98,7 +98,7 @@ public void persist(Resource resource, Vocabulary vocabulary) { } @Override - protected void preRemove(@NotNull Resource instance) { + protected void preRemove(@Nonnull Resource instance) { LOG.trace("Removing term occurrences in resource {} which is about to be removed.", instance); termOccurrenceDao.removeAll(instance); removeFromParentDocumentIfFile(instance); diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java index 24465e032..15b11b1f8 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/TermRepositoryService.java @@ -36,10 +36,10 @@ import cz.cvut.kbss.termit.service.term.OrphanedInverseTermRelationshipRemover; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Utils; +import jakarta.annotation.Nonnull; import jakarta.validation.Validator; import org.apache.jena.vocabulary.SKOS; import org.springframework.data.domain.Pageable; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -94,13 +94,13 @@ protected TermDto mapToDto(Term entity) { } @Override - public void persist(@NonNull Term instance) { + public void persist(@Nonnull Term instance) { throw new UnsupportedOperationException( "Persisting term by itself is not supported. It has to be connected to a vocabulary or a parent term."); } @Override - protected void preUpdate(@NonNull Term instance) { + protected void preUpdate(@Nonnull Term instance) { super.preUpdate(instance); // Existence check is done as part of super.preUpdate final Term original = termDao.find(instance.getUri()).get(); @@ -125,7 +125,7 @@ private void pruneEmptyTranslations(Term instance) { } @Override - protected void postUpdate(@NonNull Term instance) { + protected void postUpdate(@Nonnull Term instance) { final Vocabulary vocabulary = vocabularyService.getReference(instance.getVocabulary()); if (instance.hasParentInSameVocabulary()) { vocabulary.getGlossary().removeRootTerm(instance); @@ -406,7 +406,7 @@ public List getDefinitionallyRelatedOf(Term instance) { * @throws AssetRemovalException If the specified term cannot be removed */ @Override - protected void preRemove(@NonNull Term instance) { + protected void preRemove(@Nonnull Term instance) { super.preRemove(instance); final List ai = getOccurrenceInfo(instance); if (!ai.isEmpty()) { @@ -438,7 +438,7 @@ protected void preRemove(@NonNull Term instance) { } @Override - protected void postRemove(@NonNull Term instance) { + protected void postRemove(@Nonnull Term instance) { super.postRemove(instance); if (!instance.hasParentInSameVocabulary()) { final Vocabulary v = vocabularyService.findRequired(instance.getVocabulary()); @@ -455,7 +455,7 @@ protected void postRemove(@NonNull Term instance) { * @param instance Term to remove */ @Transactional - public void forceRemove(@NonNull Term instance) { + public void forceRemove(@Nonnull Term instance) { super.preRemove(instance); termDao.remove(instance); postRemove(instance); diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/UserGroupRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/UserGroupRepositoryService.java index 146c83d60..603c0d70c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/UserGroupRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/UserGroupRepositoryService.java @@ -24,10 +24,10 @@ import cz.cvut.kbss.termit.security.SecurityConstants; import cz.cvut.kbss.termit.service.business.UserGroupService; import cz.cvut.kbss.termit.util.Utils; +import jakarta.annotation.Nonnull; import jakarta.validation.Validator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.lang.NonNull; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -99,7 +99,7 @@ public User findRequiredUser(URI uri) { } @Override - protected void postRemove(@NonNull UserGroup instance) { + protected void postRemove(@Nonnull UserGroup instance) { super.postRemove(instance); // TODO Remove group from ACLs } diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/UserRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/UserRepositoryService.java index c98582bfb..a0729e429 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/UserRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/UserRepositoryService.java @@ -23,8 +23,8 @@ import cz.cvut.kbss.termit.persistence.dao.UserAccountDao; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; +import jakarta.annotation.Nonnull; import jakarta.validation.Validator; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -78,13 +78,13 @@ protected UserAccount mapToDto(UserAccount entity) { } @Override - protected UserAccount postLoad(@NotNull UserAccount instance) { + protected UserAccount postLoad(@Nonnull UserAccount instance) { instance.erasePassword(); return instance; } @Override - protected void prePersist(@NotNull UserAccount instance) { + protected void prePersist(@Nonnull UserAccount instance) { super.prePersist(instance); if (instance.getUri() == null) { instance.setUri(idResolver @@ -96,7 +96,7 @@ protected void prePersist(@NotNull UserAccount instance) { } @Override - protected void preUpdate(@NotNull UserAccount instance) { + protected void preUpdate(@Nonnull UserAccount instance) { final UserAccount original = userAccountDao.find(instance.getUri()).orElseThrow( () -> new NotFoundException("User " + instance + " does not exist.")); if (instance.getPassword() != null) { diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java index 0f8fede41..9d055b183 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java @@ -43,11 +43,11 @@ import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.throttle.CacheableFuture; import cz.cvut.kbss.termit.workspace.EditableVocabularies; +import jakarta.annotation.Nonnull; import jakarta.validation.Validator; import org.apache.tika.Tika; import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.TikaCoreProperties; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.CacheEvict; @@ -109,7 +109,7 @@ public List findAll() { } @Override - protected Vocabulary postLoad(@NotNull Vocabulary instance) { + protected Vocabulary postLoad(@Nonnull Vocabulary instance) { super.postLoad(instance); if (!config.getWorkspace().isAllVocabulariesEditable() && !editableVocabularies.isEditable(instance)) { instance.addType(cz.cvut.kbss.termit.util.Vocabulary.s_c_pouze_pro_cteni); @@ -125,12 +125,12 @@ protected VocabularyDto mapToDto(Vocabulary entity) { @CacheEvict(allEntries = true) @Override @Transactional - public void persist(@NotNull Vocabulary instance) { + public void persist(@Nonnull Vocabulary instance) { super.persist(instance); } @Override - protected void prePersist(@NotNull Vocabulary instance) { + protected void prePersist(@Nonnull Vocabulary instance) { super.prePersist(instance); if (instance.getUri() == null) { instance.setUri( @@ -170,7 +170,7 @@ private void initDocument(Vocabulary vocabulary) { } @Override - protected void preUpdate(@NotNull Vocabulary instance) { + protected void preUpdate(@Nonnull Vocabulary instance) { super.preUpdate(instance); final Vocabulary original = findRequired(instance.getUri()); verifyVocabularyImports(instance, original); @@ -286,7 +286,7 @@ public void remove(Vocabulary instance) { * @param instance The instance to be removed, not {@code null} */ @Override - protected void preRemove(@NotNull Vocabulary instance) { + protected void preRemove(@Nonnull Vocabulary instance) { ensureNotImported(instance); ensureNoTermRelationsExists(instance); super.preRemove(instance); diff --git a/src/main/java/cz/cvut/kbss/termit/service/security/authorization/SearchAuthorizationService.java b/src/main/java/cz/cvut/kbss/termit/service/security/authorization/SearchAuthorizationService.java index b34cb68bc..18ec738f9 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/security/authorization/SearchAuthorizationService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/security/authorization/SearchAuthorizationService.java @@ -21,7 +21,7 @@ import cz.cvut.kbss.termit.dto.search.FacetedSearchResult; import cz.cvut.kbss.termit.dto.search.FullTextSearchResult; import cz.cvut.kbss.termit.model.Vocabulary; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import org.springframework.stereotype.Service; import java.util.Objects; @@ -46,7 +46,7 @@ public SearchAuthorizationService(VocabularyAuthorizationService vocabularyAutho * @param instance Search result to authorize access to * @return {@code true} if the current user can read the specified instance, {@code false} otherwise */ - public boolean canRead(@NonNull FullTextSearchResult instance) { + public boolean canRead(@Nonnull FullTextSearchResult instance) { Objects.requireNonNull(instance); if (instance.getVocabulary() != null) { assert instance.hasType(SKOS.CONCEPT); @@ -65,7 +65,7 @@ public boolean canRead(@NonNull FullTextSearchResult instance) { * @param instance Faceted search result to authorize access to * @return {@code true} if the current user can read the specified instance, {@code false} otherwise */ - public boolean canRead(@NonNull FacetedSearchResult instance) { + public boolean canRead(@Nonnull FacetedSearchResult instance) { Objects.requireNonNull(instance); return vocabularyAuthorizationService.canRead(new Vocabulary(instance.getVocabulary())); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java index e31b081c7..4f837d609 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.util; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.util.HashSet; import java.util.Set; @@ -13,7 +13,7 @@ private ExceptionUtils() { /** * Resolves all nested causes of the {@code throwable} and returns true if any is matching the {@code cause} */ - public static boolean isCausedBy(final Throwable throwable, @NonNull final Class cause) { + public static boolean isCausedBy(final Throwable throwable, @Nonnull final Class cause) { Throwable t = throwable; final Set visited = new HashSet<>(); while (t != null) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/Pair.java b/src/main/java/cz/cvut/kbss/termit/util/Pair.java index ad0f36a34..2a576421a 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/Pair.java +++ b/src/main/java/cz/cvut/kbss/termit/util/Pair.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.util; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.util.Objects; @@ -36,7 +36,7 @@ public ComparablePair(T first, V second) { } @Override - public int compareTo(@NonNull Pair.ComparablePair o) { + public int compareTo(@Nonnull Pair.ComparablePair o) { final int firstComparison = this.getFirst().compareTo(o.getFirst()); if (firstComparison != 0) { return firstComparison; diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java index d59913ec2..a1039a916 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.util.longrunning; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.time.Instant; import java.util.Optional; @@ -35,9 +35,9 @@ public interface LongRunningTask { * @return a timestamp of the task execution start, * or empty if the task execution has not yet started. */ - @NonNull + @Nonnull Optional startedAt(); - @NonNull + @Nonnull UUID getUuid(); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java index d4c396f7c..8b1f00b47 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.util.longrunning; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; /** * An object that will schedule a long-running tasks @@ -13,7 +13,7 @@ protected LongRunningTaskScheduler(LongRunningTasksRegistry registry) { this.registry = registry; } - protected final void notifyTaskChanged(final @NonNull LongRunningTask task) { + protected final void notifyTaskChanged(final @Nonnull LongRunningTask task) { final String name = task.getName(); if (name != null && !name.isBlank()) { registry.onTaskChanged(task); diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java index aa4859c61..5e0060029 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.util.longrunning; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.io.Serializable; import java.time.Instant; @@ -19,7 +19,7 @@ public class LongRunningTaskStatus implements Serializable { private final Instant startedAt; - public LongRunningTaskStatus(@NonNull LongRunningTask task) { + public LongRunningTaskStatus(@Nonnull LongRunningTask task) { Objects.requireNonNull(task.getName()); this.name = task.getName(); this.startedAt = task.startedAt().map(time -> time.truncatedTo(ChronoUnit.SECONDS)).orElse(null); @@ -27,7 +27,7 @@ public LongRunningTaskStatus(@NonNull LongRunningTask task) { this.uuid = task.getUuid(); } - public @NonNull String getName() { + public @Nonnull String getName() { return name; } @@ -39,7 +39,7 @@ public State getState() { return startedAt; } - public @NonNull UUID getUuid() { + public @Nonnull UUID getUuid() { return uuid; } @@ -51,7 +51,7 @@ public String toString() { public enum State { PENDING, RUNNING, DONE; - public static State of(@NonNull LongRunningTask task) { + public static State of(@Nonnull LongRunningTask task) { if (task.isRunning()) { return RUNNING; } else if (task.isDone()) { diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java index a73435f4b..c8c6e31cf 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java +++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java @@ -1,11 +1,11 @@ package cz.cvut.kbss.termit.util.longrunning; import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import java.util.List; @@ -26,7 +26,7 @@ public LongRunningTasksRegistry(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } - public void onTaskChanged(@NonNull final LongRunningTask task) { + public void onTaskChanged(@Nonnull final LongRunningTask task) { final LongRunningTaskStatus status = new LongRunningTaskStatus(task); if (LOG.isTraceEnabled()) { @@ -38,7 +38,7 @@ public void onTaskChanged(@NonNull final LongRunningTask task) { eventPublisher.publishEvent(new LongRunningTaskChangedEvent(this, status)); } - private void handleTaskChanged(@NonNull final LongRunningTask task) { + private void handleTaskChanged(@Nonnull final LongRunningTask task) { if(task.isDone()) { registry.remove(task.getUuid()); } else { @@ -57,7 +57,7 @@ private void handleTaskChanged(@NonNull final LongRunningTask task) { } } - @NonNull + @Nonnull public List getTasks() { return registry.values().stream().map(LongRunningTaskStatus::new).toList(); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java index 6af5651d5..f1dd254a5 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java @@ -1,7 +1,7 @@ package cz.cvut.kbss.termit.util.throttle; import cz.cvut.kbss.termit.exception.TermItException; -import org.springframework.lang.Nullable; +import jakarta.annotation.Nullable; import java.util.Optional; import java.util.concurrent.ExecutionException; diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java index 74b31b905..a127f9a52 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -16,7 +16,7 @@ public class SynchronousTransactionExecutor implements Executor { @Transactional @Override - public void execute(@NonNull Runnable command) { + public void execute(@Nonnull Runnable command) { command.run(); } } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java index cc9c9080b..56451bf92 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java @@ -1,8 +1,8 @@ package cz.cvut.kbss.termit.util.throttle; import cz.cvut.kbss.termit.util.Constants; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -69,7 +69,7 @@ * returning a List of Objects or a String which will be used to construct the unique identifier * for this throttled instance. */ - @NonNull String value() default ""; + @Nonnull String value() default ""; /** * The Spring-EL expression @@ -93,7 +93,7 @@ * Blank string disables any group processing. * @see String#compareTo(String) */ - @NonNull String group() default ""; + @Nonnull String group() default ""; /** * @return a key name of the task which is displayed on the frontend. diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java index f86ab39d5..fdb1cfc99 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java @@ -7,6 +7,8 @@ import cz.cvut.kbss.termit.util.Pair; import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskScheduler; import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; @@ -25,8 +27,6 @@ import org.springframework.expression.spel.support.DataBindingPropertyAccessor; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.expression.spel.support.StandardTypeLocator; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; import org.springframework.scheduling.TaskScheduler; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -189,8 +189,8 @@ private static StandardEvaluationContext makeDefaultContext() { * {@link Future} * @implNote Around advice configured in {@code spring-aop.xml} */ - public @Nullable Object throttleMethodCall(@NonNull ProceedingJoinPoint joinPoint, - @NonNull Throttle throttleAnnotation) throws Throwable { + public @Nullable Object throttleMethodCall(@Nonnull ProceedingJoinPoint joinPoint, + @Nonnull Throttle throttleAnnotation) throws Throwable { // if the current thread is already executing a throttled code, we want to skip further throttling if (throttledThreads.contains(Thread.currentThread().getId())) { @@ -207,8 +207,8 @@ private static StandardEvaluationContext makeDefaultContext() { return doThrottle(joinPoint, throttleAnnotation); } - private synchronized @Nullable Object doThrottle(@NonNull ProceedingJoinPoint joinPoint, - @NonNull Throttle throttleAnnotation) throws Throwable { + private synchronized @Nullable Object doThrottle(@Nonnull ProceedingJoinPoint joinPoint, + @Nonnull Throttle throttleAnnotation) throws Throwable { final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); @@ -306,9 +306,9 @@ private EvaluationContext makeContext(JoinPoint joinPoint, Map p return context; } - private Pair> getFutureTask(@NonNull ProceedingJoinPoint joinPoint, - @NonNull Identifier identifier, - @NonNull ThrottledFuture future) + private Pair> getFutureTask(@Nonnull ProceedingJoinPoint joinPoint, + @Nonnull Identifier identifier, + @Nonnull ThrottledFuture future) throws Throwable { final MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); @@ -546,7 +546,7 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati return new Identifier(groupIdentifier, joinPoint.getSignature().toShortString() + "-" + identifier); } - private @Nullable Object resultVoidOrFuture(@NonNull MethodSignature signature, ThrottledFuture future) + private @Nullable Object resultVoidOrFuture(@Nonnull MethodSignature signature, ThrottledFuture future) throws IllegalCallerException { Class returnType = signature.getReturnType(); if (returnType.isAssignableFrom(ThrottledFuture.class)) { @@ -561,7 +561,7 @@ private Identifier makeIdentifier(JoinPoint joinPoint, Throttle throttleAnnotati @SuppressWarnings({"unchecked"}) - private @NonNull String constructIdentifier(JoinPoint joinPoint, String expression) throws ThrottleAspectException { + private @Nonnull String constructIdentifier(JoinPoint joinPoint, String expression) throws ThrottleAspectException { if (expression == null || expression.isBlank()) { return ""; } @@ -612,7 +612,7 @@ public String getIdentifier() { return this.getSecond(); } - public boolean hasGroupPrefix(@NonNull String group) { + public boolean hasGroupPrefix(@Nonnull String group) { return this.getGroup().indexOf(group) == 0 && !this.getGroup().isBlank() && !group.isBlank(); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java index 35947b403..e32f8ef40 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java +++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottledFuture.java @@ -3,8 +3,8 @@ import cz.cvut.kbss.termit.exception.TermItException; import cz.cvut.kbss.termit.util.Utils; import cz.cvut.kbss.termit.util.longrunning.LongRunningTask; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.time.Instant; import java.util.ArrayList; @@ -39,7 +39,7 @@ public class ThrottledFuture implements CacheableFuture, LongRunningTask { private @Nullable String name = null; - private ThrottledFuture(@NonNull final Supplier task) { + private ThrottledFuture(@Nonnull final Supplier task) { this.task = task; future = new CompletableFuture<>(); } @@ -48,11 +48,11 @@ protected ThrottledFuture() { future = new CompletableFuture<>(); } - public static ThrottledFuture of(@NonNull final Supplier supplier) { + public static ThrottledFuture of(@Nonnull final Supplier supplier) { return new ThrottledFuture<>(supplier); } - public static ThrottledFuture of(@NonNull final Runnable runnable) { + public static ThrottledFuture of(@Nonnull final Runnable runnable) { return new ThrottledFuture<>(() -> { runnable.run(); return null; @@ -115,7 +115,7 @@ public T get() throws InterruptedException, ExecutionException { * Does not execute the task, blocks the current thread until the result is available. */ @Override - public T get(long timeout, @NonNull TimeUnit unit) + public T get(long timeout, @Nonnull TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { return future.get(timeout, unit); } @@ -124,7 +124,7 @@ public T get(long timeout, @NonNull TimeUnit unit) * @return If the current task is already running, was canceled or already completed, returns a new future for the given task. * Otherwise, replaces the current task and returns self. */ - protected ThrottledFuture update(Supplier task, @NonNull List> onCompletion) { + protected ThrottledFuture update(Supplier task, @Nonnull List> onCompletion) { boolean locked = false; try { locked = lock.tryLock(); @@ -232,12 +232,12 @@ public boolean isRunning() { } @Override - public @NonNull Optional startedAt() { + public @Nonnull Optional startedAt() { return Optional.ofNullable(startedAt.get()); } @Override - public @NonNull UUID getUuid() { + public @Nonnull UUID getUuid() { return uuid; } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java index 55f152033..d9dd71fe5 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/BaseWebSocketController.java @@ -3,7 +3,7 @@ import cz.cvut.kbss.termit.rest.BaseController; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -38,8 +38,8 @@ protected BaseWebSocketController(IdentifierResolver idResolver, Configuration c * @param replyHeaders native headers for the reply * @param sourceHeaders original headers containing session id or name of the user */ - protected void sendToSession(@NonNull String destination, @NonNull Object payload, - @NonNull Map replyHeaders, @NonNull MessageHeaders sourceHeaders) { + protected void sendToSession(@Nonnull String destination, @Nonnull Object payload, + @Nonnull Map replyHeaders, @Nonnull MessageHeaders sourceHeaders) { getSessionId(sourceHeaders) .ifPresentOrElse(sessionId -> { // session id present StompHeaderAccessor headerAccessor = StompHeaderAccessor.create(StompCommand.MESSAGE); @@ -60,11 +60,11 @@ protected void sendToSession(@NonNull String destination, @NonNull Object payloa * * @return name or session id, or empty when information is not available. */ - protected @NonNull Optional getUser(@NonNull MessageHeaders messageHeaders) { + protected @Nonnull Optional getUser(@Nonnull MessageHeaders messageHeaders) { return getUserName(messageHeaders).or(() -> getSessionId(messageHeaders)); } - private @NonNull Optional getSessionId(@NonNull MessageHeaders messageHeaders) { + private @Nonnull Optional getSessionId(@Nonnull MessageHeaders messageHeaders) { return Optional.ofNullable(SimpMessageHeaderAccessor.getSessionId(messageHeaders)); } @@ -73,7 +73,7 @@ protected void sendToSession(@NonNull String destination, @NonNull Object payloa * * @return the name or null */ - private @NonNull Optional getUserName(MessageHeaders headers) { + private @Nonnull Optional getUserName(MessageHeaders headers) { Principal principal = SimpMessageHeaderAccessor.getUser(headers); if (principal != null) { final String name = (principal instanceof DestinationUserNameProvider provider ? diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java index f3d3ac18c..7c0bad94a 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/LongRunningTasksWebSocketController.java @@ -5,8 +5,8 @@ import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry; +import jakarta.annotation.Nonnull; import org.springframework.context.event.EventListener; -import org.springframework.lang.NonNull; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -31,7 +31,7 @@ protected LongRunningTasksWebSocketController(IdentifierResolver idResolver, Con } @SubscribeMapping("/update") - public void tasksRequest(@NonNull MessageHeaders messageHeaders) { + public void tasksRequest(@Nonnull MessageHeaders messageHeaders) { sendToSession(WebSocketDestinations.LONG_RUNNING_TASKS_UPDATE, registry.getTasks(), Map.of(), messageHeaders); } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java index 57578f45b..00c2e8b83 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/VocabularySocketController.java @@ -12,8 +12,8 @@ import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.Constants; import cz.cvut.kbss.termit.util.throttle.CacheableFuture; +import jakarta.annotation.Nonnull; import org.springframework.context.event.EventListener; -import org.springframework.lang.NonNull; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.Header; @@ -49,7 +49,7 @@ protected VocabularySocketController(IdentifierResolver idResolver, Configuratio public void validateVocabulary(@DestinationVariable String localName, @Header(name = Constants.QueryParams.NAMESPACE, required = false) Optional namespace, - @NonNull MessageHeaders messageHeaders) { + @Nonnull MessageHeaders messageHeaders) { final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName); final Vocabulary vocabulary = vocabularyService.getReference(identifier); @@ -108,15 +108,15 @@ public void onTermDefinitionTextAnalysisFinished(TermDefinitionTextAnalysisFinis ); } - protected @NonNull Map getHeaders(@NonNull VocabularyEvent event) { + protected @Nonnull Map getHeaders(@Nonnull VocabularyEvent event) { return getHeaders(event.getVocabularyIri()); } - protected @NonNull Map getHeaders(@NonNull URI vocabularyUri) { + protected @Nonnull Map getHeaders(@Nonnull URI vocabularyUri) { return getHeaders(vocabularyUri, Map.of()); } - protected @NonNull Map getHeaders(@NonNull URI vocabularyUri, Map headers) { + protected @Nonnull Map getHeaders(@Nonnull URI vocabularyUri, Map headers) { final Map headersMap = new HashMap<>(headers); headersMap.put("vocabulary", vocabularyUri); return headersMap; diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java index 4981eed7c..4aeeb1883 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/StompExceptionHandler.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.websocket.handler; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.Message; @@ -25,8 +25,8 @@ public StompExceptionHandler(WebSocketExceptionHandler webSocketExceptionHandler } @Override - protected @NotNull Message handleInternal(@NotNull StompHeaderAccessor errorHeaderAccessor, - byte @NotNull [] errorPayload, Throwable cause, + protected @Nonnull Message handleInternal(@Nonnull StompHeaderAccessor errorHeaderAccessor, + @Nonnull byte[] errorPayload, Throwable cause, StompHeaderAccessor clientHeaderAccessor) { final Message message = MessageBuilder.withPayload(errorPayload).setHeaders(errorHeaderAccessor).build(); boolean handled = false; diff --git a/src/test/java/cz/cvut/kbss/termit/environment/Transaction.java b/src/test/java/cz/cvut/kbss/termit/environment/Transaction.java index 3ccb06545..f14ecf92f 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/Transaction.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/Transaction.java @@ -17,7 +17,7 @@ */ package cz.cvut.kbss.termit.environment; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.TransactionCallbackWithoutResult; @@ -48,7 +48,7 @@ public static void execute(PlatformTransactionManager txManager, Runnable proced new TransactionTemplate(txManager).execute(new TransactionCallbackWithoutResult() { @Override - protected void doInTransactionWithoutResult(@NotNull TransactionStatus transactionStatus) { + protected void doInTransactionWithoutResult(@Nonnull TransactionStatus transactionStatus) { procedure.run(); } }); @@ -67,7 +67,7 @@ public static void executeReadOnly(PlatformTransactionManager txManager, Runnabl transaction.setReadOnly(true); new TransactionTemplate(txManager).execute(new TransactionCallbackWithoutResult() { @Override - protected void doInTransactionWithoutResult(@NotNull TransactionStatus transactionStatus) { + protected void doInTransactionWithoutResult(@Nonnull TransactionStatus transactionStatus) { procedure.run(); } }); diff --git a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java index 1ce9b63fd..a3f6cd2ed 100644 --- a/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java +++ b/src/test/java/cz/cvut/kbss/termit/environment/config/TestWebSocketConfig.java @@ -6,7 +6,7 @@ import cz.cvut.kbss.termit.security.WebSocketJwtAuthorizationInterceptor; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.websocket.util.ReturnValueCollectingSimpMessagingTemplate; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.context.TestConfiguration; @@ -60,7 +60,7 @@ public TestWebSocketConfig(List channels, List assertEquals(AssetService.MASK, ra.getLabel())); } - @NotNull + @Nonnull private List generateRecentlyModifiedWithForbiddenTerms() { final List allExpected = generateRecentlyModifiedAssets(6); when(assetDao.findLastEdited(any(Pageable.class))).thenReturn(new PageImpl<>(allExpected)); diff --git a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java index 277c26714..6cc2d505d 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/business/VocabularyServiceTest.java @@ -41,7 +41,7 @@ import cz.cvut.kbss.termit.service.security.authorization.VocabularyAuthorizationService; import cz.cvut.kbss.termit.util.Configuration; import cz.cvut.kbss.termit.util.TypeAwareResource; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -233,7 +233,7 @@ void addAccessControlRecordRetrievesACLForVocabularyAndAddsSpecifiedRecordToIt() verify(aclService).addRecord(acl, record); } - @NotNull + @Nonnull private UserAccessControlRecord generateAccessControlRecord() { final UserAccessControlRecord record = new UserAccessControlRecord(); record.setHolder(Generator.generateUserWithId()); diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java index 398b435ca..8ab60d143 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/MockedThrottle.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.lang.annotation.Annotation; @@ -13,18 +13,18 @@ public class MockedThrottle implements Throttle { private String group; - public MockedThrottle(@NonNull String value, @NonNull String group) { + public MockedThrottle(@Nonnull String value, @Nonnull String group) { this.value = value; this.group = group; } @Override - public @NonNull String value() { + public @Nonnull String value() { return value; } @Override - public @NonNull String group() { + public @Nonnull String group() { return group; } @@ -38,11 +38,11 @@ public Class annotationType() { return Throttle.class; } - public void setValue(@NonNull String value) { + public void setValue(@Nonnull String value) { this.value = value; } - public void setGroup(@NonNull String group) { + public void setGroup(@Nonnull String group) { this.group = group; } } diff --git a/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java b/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java index f48c43dbd..eb6bfb082 100644 --- a/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java +++ b/src/test/java/cz/cvut/kbss/termit/util/throttle/ScheduledFutureTask.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.util.throttle; -import org.springframework.lang.NonNull; +import jakarta.annotation.Nonnull; import java.util.concurrent.Callable; import java.util.concurrent.Delayed; @@ -10,21 +10,21 @@ public class ScheduledFutureTask extends FutureTask implements ScheduledFuture { - public ScheduledFutureTask(@NonNull Callable callable) { + public ScheduledFutureTask(@Nonnull Callable callable) { super(callable); } - public ScheduledFutureTask(@NonNull Runnable runnable, T result) { + public ScheduledFutureTask(@Nonnull Runnable runnable, T result) { super(runnable, result); } @Override - public long getDelay(@NonNull TimeUnit unit) { + public long getDelay(@Nonnull TimeUnit unit) { throw new UnsupportedOperationException("Not implemented"); } @Override - public int compareTo(@NonNull Delayed o) { + public int compareTo(@Nonnull Delayed o) { throw new UnsupportedOperationException("Not implemented"); } } diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java index a8cd731c5..fa9563fac 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/BaseWebSocketIntegrationTestRunner.java @@ -16,7 +16,7 @@ import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry; import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler; import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; @@ -150,22 +150,22 @@ public void afterConnected(StompSession session, StompHeaders connectedHeaders) } @Override - public void handleFrame(@NotNull StompHeaders headers, Object payload) { + public void handleFrame(@Nonnull StompHeaders headers, Object payload) { super.handleFrame(headers, payload); exception.set(new Exception(headers.toString())); LOG.error("STOMP frame: {}", headers); } @Override - public void handleException(@NotNull StompSession session, StompCommand command, @NotNull StompHeaders headers, - byte @NotNull [] payload, @NotNull Throwable exception) { + public void handleException(@Nonnull StompSession session, StompCommand command, @Nonnull StompHeaders headers, + @Nonnull byte[] payload, @Nonnull Throwable exception) { super.handleException(session, command, headers, payload, exception); this.exception.set(exception); LOG.error("STOMP exception", exception); } @Override - public void handleTransportError(@NotNull StompSession session, @NotNull Throwable exception) { + public void handleTransportError(@Nonnull StompSession session, @Nonnull Throwable exception) { super.handleTransportError(session, exception); this.exception.set(exception); LOG.error("STOMP transport error", exception); diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java index 43a454b81..d98a3c1a6 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/util/CachingChannelInterceptor.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.websocket.util; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.support.ChannelInterceptor; @@ -17,7 +17,7 @@ public class CachingChannelInterceptor implements ChannelInterceptor { private final BlockingQueue> messages = new ArrayBlockingQueue<>(100); @Override - public Message preSend(@NotNull Message message, @NotNull MessageChannel channel) { + public Message preSend(@Nonnull Message message, @Nonnull MessageChannel channel) { this.messages.add(message); return message; } diff --git a/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java b/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java index 6a9dd91a5..ea6ee4004 100644 --- a/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java +++ b/src/test/java/cz/cvut/kbss/termit/websocket/util/ReturnValueCollectingSimpMessagingTemplate.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.termit.websocket.util; -import org.jetbrains.annotations.NotNull; +import jakarta.annotation.Nonnull; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.core.MessagePostProcessor; @@ -28,7 +28,7 @@ public ReturnValueCollectingSimpMessagingTemplate(MessageChannel messageChannel, } @Override - protected @NotNull Message doConvert(@NotNull Object payload, Map headers, + protected @Nonnull Message doConvert(@Nonnull Object payload, Map headers, MessagePostProcessor postProcessor) { final Message converted = super.doConvert(payload, headers, postProcessor); From e2b6e5647300160eaa07d01b25fe3a07ed40d558 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Mon, 16 Sep 2024 16:51:55 +0200 Subject: [PATCH 147/150] [Bug kbss-cvut/termit-ui#511] Entity identifiers will be now validated before each persists and updates. Base repository will validate the identifier (URI) during each pre-persist and pre-update. InvalidIdentifierException caused by URISyntaxException will now provide error details to the client. --- .../exception/InvalidIdentifierException.java | 4 ++++ .../rest/handler/RestExceptionHandler.java | 24 ++++++++++++++++++- .../termit/service/IdentifierResolver.java | 8 ++++++- .../html/TextPositionSelectorGenerator.java | 3 --- .../repository/BaseRepositoryService.java | 17 +++++++++++++ .../cvut/kbss/termit/util/ExceptionUtils.java | 10 ++++---- .../handler/WebSocketExceptionHandler.java | 12 ++++++++-- 7 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java b/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java index 2ef873889..2ba55c265 100644 --- a/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java +++ b/src/main/java/cz/cvut/kbss/termit/exception/InvalidIdentifierException.java @@ -5,4 +5,8 @@ public class InvalidIdentifierException extends TermItException { public InvalidIdentifierException(String message, String messageId) { super(message, messageId); } + + public InvalidIdentifierException(String message, Throwable cause, String messageId) { + super(message, cause, messageId); + } } diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index 1b9d689ff..e879f0f34 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -40,6 +40,7 @@ import cz.cvut.kbss.termit.exception.WebServiceIntegrationException; import cz.cvut.kbss.termit.exception.importing.UnsupportedImportMediaTypeException; import cz.cvut.kbss.termit.exception.importing.VocabularyImportException; +import cz.cvut.kbss.termit.util.ExceptionUtils; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,6 +53,9 @@ import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import java.net.URISyntaxException; +import java.util.Optional; + import static cz.cvut.kbss.termit.util.ExceptionUtils.isCausedBy; /** @@ -85,7 +89,7 @@ private static void logException(Throwable ex, HttpServletRequest request) { private static void logException(String message, Throwable ex) { // Prevents exceptions caused by broken connection with a client from logging - if (!isCausedBy(ex, AsyncRequestNotUsableException.class)) { + if (isCausedBy(ex, AsyncRequestNotUsableException.class).isEmpty()) { LOG.error(message, ex); } } @@ -175,6 +179,10 @@ public ResponseEntity termItException(HttpServletRequest request, Ter @ExceptionHandler(JsonLdException.class) public ResponseEntity jsonLdException(HttpServletRequest request, JsonLdException e) { logException(e, request); + Optional uriSyntaxException = ExceptionUtils.isCausedBy(e, URISyntaxException.class); + if (uriSyntaxException.isPresent()) { + return uriSyntaxException(request, uriSyntaxException.get()); + } return new ResponseEntity<>( ErrorInfo.createWithMessage("Error when processing JSON-LD.", request.getRequestURI()), HttpStatus.INTERNAL_SERVER_ERROR); @@ -268,4 +276,18 @@ public ResponseEntity invalidIdentifierException(HttpServletRequest r logException(e, request); return new ResponseEntity<>(errorInfo(request, e), HttpStatus.CONFLICT); } + + @ExceptionHandler + public ResponseEntity uriSyntaxException(HttpServletRequest request, URISyntaxException e) { + logException(e, request); + // when the index is less than zero, its unknown, and we will use more general message + final String messageId = e.getIndex() < 0 ? "error.invalidIdentifier" : "error.invalidUriCharacter"; + TermItException exception = new InvalidIdentifierException(e.getMessage(), e, messageId) + .addParameter("uri", e.getInput()) + .addParameter("reason", e.getReason()) + .addParameter("message", e.getMessage()) + .addParameter("index", Integer.toString(e.getIndex())) + .addParameter("char", Character.toString(e.getInput().charAt(e.getIndex()))); + return new ResponseEntity<>(errorInfo(request, exception), HttpStatus.CONFLICT); + } } diff --git a/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java b/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java index fda72edb0..8da277b76 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java +++ b/src/main/java/cz/cvut/kbss/termit/service/IdentifierResolver.java @@ -152,11 +152,17 @@ public URI generateIdentifier(String namespace, String... components) { } catch (IllegalArgumentException e) { throw new InvalidIdentifierException( "Generated identifier " + namespace + localPart + " is not a valid URI.", + e, "error.identifier.invalidCharacters"); } } - private static boolean isUri(String value) { + /** + * @param value the URI to check + * @return {@code true} when the URI is prefixed with protocol ({@code http/s, ftp or file} + * and it is a valid {@link URI}. {@code false} otherwise + */ + public static boolean isUri(String value) { try { if (!value.matches("^(https?|ftp|file)://.+")) { return false; diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java index 767e06676..8f3eda5cb 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java +++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java @@ -22,11 +22,8 @@ import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; import org.jsoup.select.Elements; -import org.jsoup.select.NodeTraversor; -import org.jsoup.select.NodeVisitor; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; /** * Generates a {@link TextPositionSelector} for the specified elements. diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java index be5bcceae..1e9e2b7ad 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java +++ b/src/main/java/cz/cvut/kbss/termit/service/repository/BaseRepositoryService.java @@ -19,10 +19,12 @@ import com.fasterxml.classmate.ResolvedType; import com.fasterxml.classmate.TypeResolver; +import cz.cvut.kbss.termit.exception.InvalidIdentifierException; import cz.cvut.kbss.termit.exception.NotFoundException; import cz.cvut.kbss.termit.exception.ValidationException; import cz.cvut.kbss.termit.model.util.HasIdentifier; import cz.cvut.kbss.termit.persistence.dao.GenericDao; +import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.validation.ValidationResult; import jakarta.validation.Validator; import org.springframework.lang.NonNull; @@ -173,6 +175,7 @@ public void persist(@NonNull T instance) { */ protected void prePersist(@NonNull T instance) { validate(instance); + validateUri(instance.getUri()); } /** @@ -208,6 +211,7 @@ public T update(T instance) { * @param instance The instance to be updated, not {@code null} */ protected void preUpdate(@NonNull T instance) { + validateUri(instance.getUri()); if (!exists(instance.getUri())) { throw NotFoundException.create(instance.getClass().getSimpleName(), instance.getUri()); } @@ -282,4 +286,17 @@ protected void validate(T instance) { throw new ValidationException(validationResult); } } + + /** + * Validates the specified uri. + * + * @param uri the uri to validate + * @throws cz.cvut.kbss.termit.exception.InvalidIdentifierException when the URI is invalid + * @see cz.cvut.kbss.termit.service.IdentifierResolver#isUri(String) + */ + protected void validateUri(URI uri) throws InvalidIdentifierException { + if (uri != null && !IdentifierResolver.isUri(uri.toString())) { + throw new InvalidIdentifierException("Invalid URI: '" + uri + "'", "error.invalidIdentifier").addParameter("uri", uri.toString()); + } + } } diff --git a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java index e31b081c7..f81492494 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java @@ -3,6 +3,7 @@ import org.springframework.lang.NonNull; import java.util.HashSet; +import java.util.Optional; import java.util.Set; public class ExceptionUtils { @@ -11,21 +12,22 @@ private ExceptionUtils() { } /** - * Resolves all nested causes of the {@code throwable} and returns true if any is matching the {@code cause} + * Resolves all nested causes of the {@code throwable} + * @return any cause of the {@code throwable} matching the {@code cause} class, or empty when not found */ - public static boolean isCausedBy(final Throwable throwable, @NonNull final Class cause) { + public static Optional isCausedBy(final Throwable throwable, @NonNull final Class cause) { Throwable t = throwable; final Set visited = new HashSet<>(); while (t != null) { if(visited.add(t)) { if (cause.isInstance(t)){ - return true; + return Optional.of((T) t); } t = t.getCause(); continue; } break; } - return false; + return Optional.empty(); } } diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index a65d61a48..d41df44b7 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -38,6 +38,8 @@ import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import java.net.URISyntaxException; + import static cz.cvut.kbss.termit.util.ExceptionUtils.isCausedBy; /** @@ -74,8 +76,8 @@ private static void logException(Throwable ex, Message message) { } private static void logException(String message, Throwable ex) { - // prevents from logging exceptions caused be broken connection with a client - if (!isCausedBy(ex, AsyncRequestNotUsableException.class)) { + // prevents from logging exceptions caused by broken connection with a client + if (isCausedBy(ex, AsyncRequestNotUsableException.class).isEmpty()) { LOG.error(message, ex); } } @@ -263,4 +265,10 @@ public ErrorInfo invalidIdentifierException(Message message, InvalidIdentifie logException(e, message); return errorInfo(message, e); } + + @MessageExceptionHandler + public ErrorInfo uriSyntaxException(Message message, URISyntaxException e) { + logException(e, message); + return errorInfo(message, e); + } } From 41fbc75f116d699c18cb62318ad100ec0ecd8b62 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Tue, 17 Sep 2024 07:58:37 +0200 Subject: [PATCH 148/150] [Ref] fix inline comment in WebSocketExceptionHandler --- .../termit/websocket/handler/WebSocketExceptionHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index d41df44b7..8b0436b87 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -76,7 +76,7 @@ private static void logException(Throwable ex, Message message) { } private static void logException(String message, Throwable ex) { - // prevents from logging exceptions caused by broken connection with a client + // Prevents exceptions caused by broken connection with a client from logging if (isCausedBy(ex, AsyncRequestNotUsableException.class).isEmpty()) { LOG.error(message, ex); } From 1526072b481913d06027a224ebc57cf17113cb72 Mon Sep 17 00:00:00 2001 From: lukaskabc Date: Thu, 19 Sep 2024 10:27:07 +0200 Subject: [PATCH 149/150] [Bug kbss-cvut/termit-ui#511] rename method in ExceptionUtils Rename isCausedBy to findCause to reflect changed return type. --- .../cvut/kbss/termit/rest/handler/RestExceptionHandler.java | 6 +++--- src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java | 2 +- .../termit/websocket/handler/WebSocketExceptionHandler.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java index e879f0f34..0ea71c47c 100644 --- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java @@ -56,7 +56,7 @@ import java.net.URISyntaxException; import java.util.Optional; -import static cz.cvut.kbss.termit.util.ExceptionUtils.isCausedBy; +import static cz.cvut.kbss.termit.util.ExceptionUtils.findCause; /** * Exception handlers for REST controllers. @@ -89,7 +89,7 @@ private static void logException(Throwable ex, HttpServletRequest request) { private static void logException(String message, Throwable ex) { // Prevents exceptions caused by broken connection with a client from logging - if (isCausedBy(ex, AsyncRequestNotUsableException.class).isEmpty()) { + if (findCause(ex, AsyncRequestNotUsableException.class).isEmpty()) { LOG.error(message, ex); } } @@ -179,7 +179,7 @@ public ResponseEntity termItException(HttpServletRequest request, Ter @ExceptionHandler(JsonLdException.class) public ResponseEntity jsonLdException(HttpServletRequest request, JsonLdException e) { logException(e, request); - Optional uriSyntaxException = ExceptionUtils.isCausedBy(e, URISyntaxException.class); + Optional uriSyntaxException = ExceptionUtils.findCause(e, URISyntaxException.class); if (uriSyntaxException.isPresent()) { return uriSyntaxException(request, uriSyntaxException.get()); } diff --git a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java index f81492494..8a445e629 100644 --- a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java +++ b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java @@ -15,7 +15,7 @@ private ExceptionUtils() { * Resolves all nested causes of the {@code throwable} * @return any cause of the {@code throwable} matching the {@code cause} class, or empty when not found */ - public static Optional isCausedBy(final Throwable throwable, @NonNull final Class cause) { + public static Optional findCause(final Throwable throwable, @NonNull final Class cause) { Throwable t = throwable; final Set visited = new HashSet<>(); while (t != null) { diff --git a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java index 8b0436b87..c5869701b 100644 --- a/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java +++ b/src/main/java/cz/cvut/kbss/termit/websocket/handler/WebSocketExceptionHandler.java @@ -40,7 +40,7 @@ import java.net.URISyntaxException; -import static cz.cvut.kbss.termit.util.ExceptionUtils.isCausedBy; +import static cz.cvut.kbss.termit.util.ExceptionUtils.findCause; /** * @implSpec Should reflect {@link cz.cvut.kbss.termit.rest.handler.RestExceptionHandler} @@ -77,7 +77,7 @@ private static void logException(Throwable ex, Message message) { private static void logException(String message, Throwable ex) { // Prevents exceptions caused by broken connection with a client from logging - if (isCausedBy(ex, AsyncRequestNotUsableException.class).isEmpty()) { + if (findCause(ex, AsyncRequestNotUsableException.class).isEmpty()) { LOG.error(message, ex); } } From 91d8f5f8587acb5a0dfdbeacc82525741e9732f9 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 23 Sep 2024 16:57:26 +0200 Subject: [PATCH 150/150] [3.2.0] Bump version. --- pom.xml | 2 +- src/main/resources/application.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index ab4877fd8..a46eb45cc 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ termit - 3.1.3 + 3.2.0 TermIt Terminology manager based on Semantic Web technologies. ${packaging} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6a60b32ac..8d9cae801 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,6 +19,7 @@ server: name: TermItSessionId tomcat: max-swallow-size: -1 + forward-headers-strategy: framework spring: servlet: multipart: @@ -66,7 +67,7 @@ termit: file: storage: /tmp/termit textAnalysis: - url: http://localhost/annotace/annotate + url: http://localhost:8081/annotace/annotate changetracking: context: extension: /zmeny

    + * Extreme caution should be exercised when using this method as it does not perform any checks before removing the + * specified instance. + * + * @param instance Term to remove + */ + @Transactional + public void forceRemove(@NonNull Term instance) { + super.preRemove(instance); + termDao.remove(instance); + postRemove(instance); + } + @Override public List findSnapshots(Term asset) { return termDao.findSnapshots(asset); diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index bfc780afb..9b6c99f66 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -1,5 +1,6 @@ package cz.cvut.kbss.termit.service.importer.excel; +import cz.cvut.kbss.jopa.model.EntityManager; import cz.cvut.kbss.jopa.model.MultilingualString; import cz.cvut.kbss.jopa.vocabulary.DC; import cz.cvut.kbss.jopa.vocabulary.SKOS; @@ -64,6 +65,10 @@ class ExcelImporterTest { @Spy private Configuration config = new Configuration(); + @SuppressWarnings("unused") + @Mock + private EntityManager em; + @SuppressWarnings("unused") @Spy private IdentifierResolver idResolver = new IdentifierResolver(); @@ -442,4 +447,31 @@ void importAdjustsTermIdentifiersToUseExistingVocabularyIdentifierAndSeparatorAs vocabulary.getUri().toString() + config.getNamespace().getTerm().getSeparator() + "/construction"), t.getUri()))); } + + @Test + void importRemovesExistingInstanceWhenImportedTermAlreadyExists() { + vocabulary.setUri(URI.create("http://example.com")); + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); + final Term existingBuilding = Generator.generateTermWithId(); + existingBuilding.setUri(URI.create("http://example.com/terms/building")); + final Term existingConstruction = Generator.generateTermWithId(); + existingConstruction.setUri(URI.create("http://example.com/terms/construction")); + when(termService.exists(existingBuilding.getUri())).thenReturn(true); + when(termService.exists(existingConstruction.getUri())).thenReturn(true); + when(termService.findRequired(existingBuilding.getUri())).thenReturn(existingBuilding); + when(termService.findRequired(existingConstruction.getUri())).thenReturn(existingConstruction); + + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-identifiers-en.xlsx"))); + assertEquals(vocabulary, result); + verify(termService).forceRemove(existingBuilding); + verify(termService).forceRemove(existingConstruction); + final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); + } } From 0a54b0937b0081a1bc65b1f677c9142f466b3664 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 12 Aug 2024 17:23:53 +0200 Subject: [PATCH 034/150] [kbss-cvut/termit-ui#449] Support identifier-based references to terms from other vocabularies in Excel import. skos:relatedMatch and skos:exactMatch can thus be used, but the values in the columns must be identifiers of existing terms. --- .../service/importer/excel/ExcelImporter.java | 3 +- .../excel/LocalizedSheetImporter.java | 24 +++++++++++--- src/main/resources/attributes/cs.properties | 2 ++ src/main/resources/attributes/en.properties | 4 +-- .../resources/template/termit-import.xlsx | Bin 65560 -> 59064 bytes .../importer/excel/ExcelImporterTest.java | 31 ++++++++++++++++++ .../import-with-external-references-en.xlsx | Bin 0 -> 35741 bytes 7 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 src/test/resources/data/import-with-external-references-en.xlsx diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index acb3dc4da..b148f00d6 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -89,7 +89,8 @@ public Vocabulary importVocabulary(ImportConfiguration config, ImportInput data) // Skip already processed prefix sheet continue; } - final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter(prefixMap, terms, + final LocalizedSheetImporter sheetImporter = new LocalizedSheetImporter(termService, prefixMap, + terms, idResolver, termNamespace); terms = sheetImporter.resolveTermsFromSheet(sheet); rawDataToInsert.addAll(sheetImporter.getRawDataToInsert()); diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java index 463495c4e..071ac84f8 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/LocalizedSheetImporter.java @@ -8,6 +8,7 @@ import cz.cvut.kbss.termit.model.Term; import cz.cvut.kbss.termit.service.IdentifierResolver; import cz.cvut.kbss.termit.service.export.util.TabularTermExportUtils; +import cz.cvut.kbss.termit.service.repository.TermRepositoryService; import cz.cvut.kbss.termit.util.Utils; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; @@ -45,6 +46,7 @@ class LocalizedSheetImporter { private static final String FALLBACK_LANGUAGE = "en"; + private final TermRepositoryService termRepositoryService; private final PrefixMap prefixMap; private final List existingTerms; private final IdentifierResolver idResolver; @@ -58,8 +60,10 @@ class LocalizedSheetImporter { private final Map idToTerm = new HashMap<>(); private List rawDataToInsert; - LocalizedSheetImporter(PrefixMap prefixMap, List existingTerms, IdentifierResolver idResolver, + LocalizedSheetImporter(TermRepositoryService termRepositoryService, PrefixMap prefixMap, List existingTerms, + IdentifierResolver idResolver, String expectedTermNamespace) { + this.termRepositoryService = termRepositoryService; this.prefixMap = prefixMap; this.existingTerms = existingTerms; this.idResolver = idResolver; @@ -193,7 +197,11 @@ private void mapRowToTermAttributes(Term term, Row termRow) { getAttributeValue(termRow, DC.Terms.REFERENCES).ifPresent( nt -> term.setProperties(Collections.singletonMap(DC.Terms.REFERENCES, splitIntoMultipleValues(nt)))); getAttributeValue(termRow, SKOS.RELATED).ifPresent( - rt -> mapSkosRelationship(term, splitIntoMultipleValues(rt), SKOS.RELATED)); + rt -> mapSkosRelated(term, splitIntoMultipleValues(rt))); + getAttributeValue(termRow, SKOS.RELATED_MATCH).ifPresent( + rtm -> mapSkosMatchProperties(term, SKOS.RELATED_MATCH, splitIntoMultipleValues(rtm))); + getAttributeValue(termRow, SKOS.EXACT_MATCH).ifPresent( + exm -> mapSkosMatchProperties(term, SKOS.EXACT_MATCH, splitIntoMultipleValues(exm))); } private MultilingualString initSingularMultilingualString(Supplier getter, @@ -234,14 +242,14 @@ private void setParentTerms(Term term, Set parents) { }); } - private void mapSkosRelationship(Term subject, Set objects, String property) { - final URI propertyUri = URI.create(property); + private void mapSkosRelated(Term subject, Set objects) { + final URI propertyUri = URI.create(SKOS.RELATED); objects.forEach(object -> { try { final Term objectTerm = getTerm(object); if (objectTerm == null) { LOG.warn("No term with label '{}' found for term '{}' and relationship <{}>.", object, - subject.getLabel().get(langTag), property); + subject.getLabel().get(langTag), SKOS.RELATED); } else { // Term IDs may not be generated, yet rawDataToInsert.add(new ExcelImporter.TermRelationship(subject, propertyUri, objectTerm)); @@ -258,6 +266,12 @@ private Term getTerm(String identification) { idToTerm.get(URI.create(prefixMap.resolvePrefixed(identification))); } + private void mapSkosMatchProperties(Term subject, String property, Set objects) { + final URI propertyUri = URI.create(property); + objects.stream().map(id -> URI.create(prefixMap.resolvePrefixed(id))).filter(termRepositoryService::exists) + .forEach(uri -> rawDataToInsert.add(new ExcelImporter.TermRelationship(subject, propertyUri, new Term(uri)))); + } + List getRawDataToInsert() { return rawDataToInsert; } diff --git a/src/main/resources/attributes/cs.properties b/src/main/resources/attributes/cs.properties index 491de5ac9..daa60bb25 100644 --- a/src/main/resources/attributes/cs.properties +++ b/src/main/resources/attributes/cs.properties @@ -8,5 +8,7 @@ http\://www.w3.org/2004/02/skos/core#notation=Notace http\://purl.org/dc/terms/source=Zdroj http\://www.w3.org/2004/02/skos/core#broader=Nad\u0159azené pojmy http\://www.w3.org/2004/02/skos/core#related=Související pojmy +http\://www.w3.org/2004/02/skos/core#relatedMatch=Externí související pojmy +http\://www.w3.org/2004/02/skos/core#exactMatch=Pojmy se stejným významem http\://purl.org/dc/terms/references=Reference @id=Identifikátor diff --git a/src/main/resources/attributes/en.properties b/src/main/resources/attributes/en.properties index 1ae95193a..93ded3cb1 100644 --- a/src/main/resources/attributes/en.properties +++ b/src/main/resources/attributes/en.properties @@ -8,7 +8,7 @@ http\://www.w3.org/2004/02/skos/core#notation=Notation http\://purl.org/dc/terms/source=Source http\://www.w3.org/2004/02/skos/core#broader=Parent terms http\://www.w3.org/2004/02/skos/core#related=Related terms +http\://www.w3.org/2004/02/skos/core#relatedMatch=Related match terms +http\://www.w3.org/2004/02/skos/core#exactMatch=Exact matches http\://purl.org/dc/terms/references=References @id=Identifier - - diff --git a/src/main/resources/template/termit-import.xlsx b/src/main/resources/template/termit-import.xlsx index fe734b69f143f797b0b280a77eb7b34b8e3801fe..aad4a81f1fdab7e996d0995f167b7539bf10a579 100644 GIT binary patch literal 59064 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 bwP$H1;4ZW80-CL((^K^y=tQwn_1*sfQk1*3 diff --git a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java index 9b6c99f66..3625c31f5 100644 --- a/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java +++ b/src/test/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporterTest.java @@ -474,4 +474,35 @@ void importRemovesExistingInstanceWhenImportedTermAlreadyExists() { final ArgumentCaptor captor = ArgumentCaptor.forClass(Term.class); verify(termService, times(2)).addRootTermToVocabulary(captor.capture(), eq(vocabulary)); } + + @Test + void importSupportsReferencesToOtherVocabulariesViaTermIdentifiersWhenReferencedTermsExist() { + vocabulary.setUri(URI.create("http://example.com")); + when(vocabularyDao.exists(vocabulary.getUri())).thenReturn(true); + when(vocabularyDao.find(vocabulary.getUri())).thenReturn(Optional.of(vocabulary)); + initIdentifierGenerator(Constants.DEFAULT_LANGUAGE, false); + when(termService.exists(any())).thenReturn(false); + when(termService.exists(URI.create("http://example.com/another-vocabulary/terms/relatedMatch"))).thenReturn( + true); + when(termService.exists(URI.create("http://example.com/another-vocabulary/terms/exactMatch"))).thenReturn(true); + + final Vocabulary result = sut.importVocabulary( + new VocabularyImporter.ImportConfiguration(false, vocabulary.getUri(), prePersist), + new VocabularyImporter.ImportInput(Constants.MediaType.EXCEL, + Environment.loadFile( + "data/import-with-external-references-en.xlsx"))); + assertEquals(vocabulary, result); + final ArgumentCaptor termCaptor = ArgumentCaptor.forClass(Term.class); + verify(termService, times(2)).addRootTermToVocabulary(termCaptor.capture(), eq(vocabulary)); + final ArgumentCaptor> quadsCaptor = ArgumentCaptor.forClass(Collection.class); + verify(dataDao).insertRawData(quadsCaptor.capture()); + assertEquals(2, quadsCaptor.getValue().size()); + assertEquals(List.of(new Quad(termCaptor.getAllValues().get(0).getUri(), URI.create(SKOS.RELATED_MATCH), + URI.create("http://example.com/another-vocabulary/terms/relatedMatch"), + vocabulary.getUri()), + (new Quad(termCaptor.getAllValues().get(1).getUri(), URI.create(SKOS.EXACT_MATCH), + URI.create("http://example.com/another-vocabulary/terms/exactMatch"), + vocabulary.getUri()))), + quadsCaptor.getValue()); + } } diff --git a/src/test/resources/data/import-with-external-references-en.xlsx b/src/test/resources/data/import-with-external-references-en.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d96dfdc3279ffbeee667f7730db7db8c07e1902b GIT binary patch literal 35741 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 Wmck&|Q#*FP&}*=dDlScZ_x}Kprb$}> literal 0 HcmV?d00001 From f756fa5acc581fbbe437be2306e871cc43575e4c Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Mon, 12 Aug 2024 17:34:51 +0200 Subject: [PATCH 035/150] [kbss-cvut/termit-ui#449] Document ExcelImporter. --- .../service/importer/excel/ExcelImporter.java | 18 ++++++++++++++++++ .../importer/excel/LocalizedSheetImporter.java | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java index b148f00d6..d87192b4c 100644 --- a/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java +++ b/src/main/java/cz/cvut/kbss/termit/service/importer/excel/ExcelImporter.java @@ -35,6 +35,24 @@ import java.util.Objects; import java.util.Set; +/** + * Imports a vocabulary from an Excel file. + *