Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
- Include tenant ID in cache [MODLD-952](https://folio-org.atlassian.net/browse/MODLD-952)
- Handle free form language text values in HUBs in GET, PUT /resource APIs [MODLD-977](https://folio-org.atlassian.net/browse/MODLD-977)
- Index modified Hubs in mod-search [MODLD-969](https://folio-org.atlassian.net/browse/MODLD-969)
- Hub preview API [MODLD-957](https://folio-org.atlassian.net/browse/MODLD-957)

## 1.0.4 (04-24-2025)
- Work Edit form - Instance read-only section: "Notes about the instance" data is not shown [MODLD-716](https://folio-org.atlassian.net/browse/MODLD-716)
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ curl --location '{{ base-uri }}/linked-data/resource/{id}/graph' \
--header 'x-okapi-tenant: {tenant}' \
--header 'x-okapi-token: {token}'
```

### Preview of a remote Hub resource
A preview of a remote Hub resource can be retrieved by making a GET request to the `/linked-data/hub` endpoint with the Hub URI as a query parameter.

# Integration with FOLIO
When running in FOLIO mode, this module integrates with multiple Folio modules via Kafka.
## Search module
Expand Down
11 changes: 11 additions & 0 deletions descriptors/ModuleDescriptor-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@
"source-storage.records.formatted.item.get",
"search.authorities.collection.get"
]
},
{
"methods": [ "GET" ],
"pathPattern": "/linked-data/hub",
"permissionsRequired": [ "linked-data.hub.preview.get" ],
"modulePermissions": []
}
]
},
Expand Down Expand Up @@ -300,6 +306,11 @@
"permissionName": "linked-data.resources.rdf.get",
"displayName": "Linked Data: Export an Instance to RDF JSON-LD",
"description": "Export an Instance to RDF JSON-LD"
},
{
"permissionName": "linked-data.hub.preview.get",
"displayName": "Linked Data: Get the preview of a remote Hub resource",
"description": "Get the preview of a remote Hub resource by Hub URI"
}
],
"launchDescriptor": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.folio.linked.data.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

@Configuration
public class RestClientConfiguration {

@Bean
public RestClient restClient() {
return RestClient.builder()
.requestFactory(new JdkClientHttpRequestFactory())
.build();
}
}
21 changes: 21 additions & 0 deletions src/main/java/org/folio/linked/data/controller/HubController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.folio.linked.data.controller;

import lombok.RequiredArgsConstructor;
import org.folio.linked.data.domain.dto.ResourceResponseDto;
import org.folio.linked.data.rest.resource.HubApi;
import org.folio.linked.data.service.hub.HubService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class HubController implements HubApi {

private final HubService hubService;

@Override
public ResponseEntity<ResourceResponseDto> getHubPreviewByUri(String hubUri) {
return ResponseEntity.ok(hubService.previewHub(hubUri));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public RequestProcessingException notFoundSourceRecordException(String idType, S
return requestProcessingException(notFoundError, "Source Record", idType, idValue, "Source Record storage");
}

public RequestProcessingException notFoundHubByUriException(String hubUri) {
var notFoundError = errorResponseConfig.getNotFound();
return requestProcessingException(notFoundError, "Hub", "URI", hubUri, "remote source");
}

public RequestProcessingException failedDependencyException(String message, String reason) {
return requestProcessingException(errorResponseConfig.getFailedDependency(), message, reason);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.folio.linked.data.integration.http;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
@RequiredArgsConstructor
public class HttpClient {

private final RestClient restClient;

public String downloadString(String url) {
return restClient.get()
.uri(url)
.retrieve()
.body(String.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.folio.linked.data.service.hub;

import org.folio.linked.data.domain.dto.ResourceResponseDto;

public interface HubService {

ResourceResponseDto previewHub(String hubUri);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.folio.linked.data.service.hub;

import static org.apache.commons.lang3.StringUtils.substringAfterLast;
import static org.apache.commons.lang3.StringUtils.substringBefore;
import static org.folio.ld.dictionary.PropertyDictionary.LINK;
import static org.folio.linked.data.mapper.dto.resource.base.SingleResourceMapperUnit.ResourceMappingContext;
import static org.folio.linked.data.util.ResourceUtils.getPropertyValues;

import lombok.RequiredArgsConstructor;
import org.folio.linked.data.domain.dto.ResourceResponseDto;
import org.folio.linked.data.exception.RequestProcessingExceptionBuilder;
import org.folio.linked.data.integration.http.HttpClient;
import org.folio.linked.data.mapper.dto.resource.hub.HubMapperUnit;
import org.folio.linked.data.service.rdf.RdfImportService;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class HubServiceImpl implements HubService {

private final HttpClient httpClient;
private final HubMapperUnit hubMapperUnit;
private final RdfImportService rdfImportService;
private final RequestProcessingExceptionBuilder requestProcessingExceptionBuilder;

@Override
public ResourceResponseDto previewHub(String hubUri) {
var jsonString = httpClient.downloadString(hubUri);
var imported = rdfImportService.importRdfJsonString(jsonString, false);
var id = substringBefore(substringAfterLast(hubUri, "/"), ".");
return imported.stream()
.filter(r -> getPropertyValues(r, LINK).stream().anyMatch(p -> p.contains(id)))
.map(hubResource -> {
var rmc = new ResourceMappingContext(null, null);
return hubMapperUnit.toDto(hubResource, new ResourceResponseDto(), rmc);
})
.findFirst()
.orElseThrow(() -> requestProcessingExceptionBuilder.notFoundHubByUriException(hubUri));
}

}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package org.folio.linked.data.service.rdf;

import java.time.OffsetDateTime;
import java.util.Set;
import org.folio.linked.data.domain.dto.ImportFileResponseDto;
import org.folio.linked.data.domain.dto.ImportOutputEvent;
import org.folio.linked.data.model.entity.Resource;
import org.springframework.web.multipart.MultipartFile;

public interface RdfImportService {
Expand All @@ -11,4 +13,6 @@ public interface RdfImportService {

void importOutputEvent(ImportOutputEvent event, OffsetDateTime startTime);

Set<Resource> importRdfJsonString(String rdfJson, Boolean save);

}
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
package org.folio.linked.data.service.rdf;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toSet;
import static org.folio.linked.data.util.ImportUtils.APPLICATION_LD_JSON_VALUE;
import static org.folio.linked.data.util.ImportUtils.ImportReport;
import static org.folio.linked.data.util.ImportUtils.ImportedResource;
import static org.folio.linked.data.util.ImportUtils.Status;
import static org.folio.linked.data.util.ImportUtils.Status.CONVERTED;
import static org.folio.linked.data.util.ImportUtils.Status.CREATED;
import static org.folio.linked.data.util.ImportUtils.Status.FAILED;
import static org.folio.linked.data.util.ImportUtils.Status.UPDATED;
import static org.folio.linked.data.util.ImportUtils.toRdfMediaType;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.folio.linked.data.domain.dto.ImportFileResponseDto;
Expand All @@ -18,10 +29,10 @@
import org.folio.linked.data.exception.RequestProcessingExceptionBuilder;
import org.folio.linked.data.mapper.ResourceModelMapper;
import org.folio.linked.data.mapper.kafka.ldimport.ImportEventResultMapper;
import org.folio.linked.data.model.entity.Resource;
import org.folio.linked.data.service.lccn.LccnResourceService;
import org.folio.linked.data.service.resource.graph.ResourceGraphService;
import org.folio.linked.data.service.resource.meta.MetadataService;
import org.folio.linked.data.util.ImportUtils;
import org.folio.rdf4ld.service.Rdf4LdService;
import org.folio.spring.tools.kafka.FolioMessageProducer;
import org.springframework.beans.factory.annotation.Autowired;
Expand Down Expand Up @@ -50,9 +61,7 @@ public class RdfImportServiceImpl implements RdfImportService {
@Override
public ImportFileResponseDto importFile(MultipartFile multipartFile) {
try (var is = multipartFile.getInputStream()) {
var resources = rdf4LdService.mapBibframe2RdfToLd(is, ImportUtils.toRdfMediaType(multipartFile.getContentType()));
var resourcesWithLineNumbers = resources.stream().map(r -> new ResourceWithLineNumber(1L, r)).collect(toSet());
var importReport = doImport(resourcesWithLineNumbers);
var importReport = importInputStream(is, toRdfMediaType(multipartFile.getContentType()), true);
var reportCsv = importReport.toCsv();
return new ImportFileResponseDto(importReport.getIdsWithStatus(CREATED, UPDATED), reportCsv);
} catch (IOException e) {
Expand All @@ -66,13 +75,35 @@ public ImportFileResponseDto importFile(MultipartFile multipartFile) {
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void importOutputEvent(ImportOutputEvent event, OffsetDateTime startTime) {
var report = doImport(event.getResourcesWithLineNumbers());
var report = doImport(event.getResourcesWithLineNumbers(), true);
var importEventResult = importEventResultMapper.fromImportReport(event, startTime, report);
importResultEventProducer.sendMessages(List.of(importEventResult));
}

private ImportUtils.ImportReport doImport(Set<ResourceWithLineNumber> resourcesWithLineNumbers) {
var report = new ImportUtils.ImportReport();
@Override
public Set<Resource> importRdfJsonString(String rdfJson, Boolean save) {
try (var inputStream = new ByteArrayInputStream(rdfJson.getBytes(UTF_8))) {
var importReport = importInputStream(inputStream, APPLICATION_LD_JSON_VALUE, save);
return importReport.getImports().stream()
.map(ImportedResource::getResourceEntity)
.filter(Objects::nonNull)
.collect(toSet());
} catch (IOException e) {
throw exceptionBuilder.badRequestException("Rdf JSON import error", e.getMessage());
}
}

private ImportReport importInputStream(InputStream input, String contentType, Boolean save) {
var resources = rdf4LdService.mapBibframe2RdfToLd(input, contentType);
var lineNumber = new AtomicLong(1);
var resourcesWithLineNumbers = resources.stream()
.map(r -> new ResourceWithLineNumber(lineNumber.getAndIncrement(), r))
.collect(toSet());
return doImport(resourcesWithLineNumbers, save);
}

private ImportReport doImport(Set<ResourceWithLineNumber> resourcesWithLineNumbers, boolean save) {
var report = new ImportReport();
var mockResourcesSearchResult = lccnResourceService.findMockResources(
resourcesWithLineNumbers.stream().map(ResourceWithLineNumber::getResource).collect(toSet())
);
Expand All @@ -82,12 +113,17 @@ private ImportUtils.ImportReport doImport(Set<ResourceWithLineNumber> resourcesW
mockResourcesSearchResult);
var resource = resourceModelMapper.toEntity(resourceModel);
metadataService.ensure(resource);
var saveGraphResult = resourceGraphService.saveMergingGraphInNewTransaction(resource);
var status = saveGraphResult.newResources().contains(resource) ? CREATED : UPDATED;
report.addImport(new ImportUtils.ImportedResource(resourceWithLineNumber, status, null));
Status status;
if (save) {
var saveGraphResult = resourceGraphService.saveMergingGraphInNewTransaction(resource);
status = saveGraphResult.newResources().contains(resource) ? CREATED : UPDATED;
} else {
status = CONVERTED;
}
report.addImport(new ImportedResource(resourceWithLineNumber, status, null, resource));
} catch (Exception e) {
log.debug("Exception during import of a resource from line {}", resourceWithLineNumber.getLineNumber(), e);
report.addImport(new ImportUtils.ImportedResource(resourceWithLineNumber, FAILED, e.getMessage()));
report.addImport(new ImportedResource(resourceWithLineNumber, FAILED, e.getMessage(), null));
}
});
return report;
Expand Down
12 changes: 8 additions & 4 deletions src/main/java/org/folio/linked/data/util/ImportUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
import lombok.extern.log4j.Log4j2;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.folio.ld.dictionary.model.Resource;
import org.folio.linked.data.domain.dto.ResourceWithLineNumber;
import org.folio.linked.data.model.entity.Resource;

@Log4j2
@UtilityClass
Expand Down Expand Up @@ -55,6 +55,7 @@ private enum ReportHeader {
public enum Status {
CREATED("Created"),
UPDATED("Updated"),
CONVERTED("Converted"),
FAILED("Failed");

private final String value;
Expand All @@ -67,15 +68,18 @@ public static class ImportedResource {
private Long lineNumber;
private Status status;
private String failureReason;
private Resource failedResource;
private Resource resourceEntity;

public ImportedResource(ResourceWithLineNumber resourceWithLineNumber, Status status, String failureReason) {
public ImportedResource(ResourceWithLineNumber resourceWithLineNumber,
Status status,
String failureReason,
Resource resourceEntity) {
this.id = resourceWithLineNumber.getResource().getId();
this.label = resourceWithLineNumber.getResource().getLabel();
this.lineNumber = resourceWithLineNumber.getLineNumber();
this.status = status;
this.failureReason = failureReason;
this.failedResource = status == FAILED ? resourceWithLineNumber.getResource() : null;
this.resourceEntity = resourceEntity;
}
}

Expand Down
27 changes: 27 additions & 0 deletions src/main/resources/swagger.api/mod-linked-data.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,26 @@ paths:
'500':
$ref: '#/components/responses/internalServerErrorResponse'

/linked-data/hub:
get:
operationId: getHubPreviewByUri
tags:
- hub
description: Get the preview of a remote Hub resource
parameters:
- $ref: '#/components/parameters/hubUri'
responses:
'200':
description: Resource as json string
content:
application/json:
schema:
$ref: schema/resourceResponseDto.json
'404':
description: No Hub resource is found by Hub URI
'500':
$ref: '#/components/responses/internalServerErrorResponse'

components:
parameters:
id:
Expand Down Expand Up @@ -491,6 +511,13 @@ components:
schema:
type: integer
format: int16
hubUri:
name: hubUri
in: query
required: true
description: URI of a remote Hub resource
schema:
type: string
schemas:
errorResponse:
$ref: schema/error/errors.json
Expand Down
Loading