diff --git a/static/plugins/uv/uv_init.js b/static/plugins/uv/uv_init.js index 9244b3905b..e90b327ad5 100644 --- a/static/plugins/uv/uv_init.js +++ b/static/plugins/uv/uv_init.js @@ -5,7 +5,7 @@ try { let iiifurlAdaptor = new UV.IIIFURLAdaptor() let data = iiifurlAdaptor.getInitialData({ - manifest: 'jp2Proxy/' + $UV.dataset.url + '/jp2/manifest', + manifest: 'services/api/iiif/v3/' + $UV.dataset.url + '/manifest', locales: [{ name: "en-GB" }] }); let viewer = UV.init('jp2_viewer', data); diff --git a/web-services-app/pom.xml b/web-services-app/pom.xml index 35e44b0794..ac8b7f2410 100644 --- a/web-services-app/pom.xml +++ b/web-services-app/pom.xml @@ -166,6 +166,12 @@ wiremock-jre8 test + + info.freelibrary + jiiify-presentation-v3 + + 0.12.3 + jakarta.servlet diff --git a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/DownloadImageService.java b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/DownloadImageService.java index 278f94ebe5..7a00756abb 100644 --- a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/DownloadImageService.java +++ b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/DownloadImageService.java @@ -1,10 +1,10 @@ package edu.unc.lib.boxc.web.services.processing; -import static edu.unc.lib.boxc.model.fcrepo.ids.RepositoryPaths.idToPath; import edu.unc.lib.boxc.model.api.DatastreamType; import edu.unc.lib.boxc.search.api.models.ContentObjectRecord; import edu.unc.lib.boxc.search.api.models.Datastream; +import edu.unc.lib.boxc.web.services.utils.ImageServerUtil; import org.apache.commons.io.FilenameUtils; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; @@ -16,20 +16,21 @@ import java.net.URL; import java.util.Objects; +import static edu.unc.lib.boxc.web.services.utils.ImageServerUtil.FULL_SIZE; + /** * Service to process access copy image downloads * @author snluong */ public class DownloadImageService { private String iiifBasePath; - public static final String FULL_SIZE = "full"; + public static final String INVALID_SIZE_MESSAGE = "Unable to determine size for access copy download"; /** * Method contacts the IIIF server for the requested access copy image and returns it * @param contentObjectRecord solr record of the file - * @param size a string which is either "full" for full size or a pixel length like "1200" - * @param pidString the UUID of the file + * @param size a string which is either "max" for full size or a pixel length like "1200" * @return a response entity which contains headers and content of the access copy image * @throws IOException */ @@ -40,7 +41,7 @@ public ResponseEntity streamImage(ContentObjectRecord conte } String pidString = contentObjectRecord.getPid().getId(); - String url = buildURL(pidString, size); + String url = ImageServerUtil.buildURL(iiifBasePath, pidString, size); InputStream input = new URL(url).openStream(); InputStreamResource resource = new InputStreamResource(input); String filename = getDownloadFilename(contentObjectRecord, size); @@ -51,22 +52,7 @@ public ResponseEntity streamImage(ContentObjectRecord conte .body(resource); } - /** - * A method that builds the IIIF URL based on an assumption of full region, 0 rotation, and default quality. - * @param id the UUID of the file - * @param size a string which is either "full" for full size or a pixel length like "1200" - * @return a string which is the URL to request the IIIF server for the image - */ - private String buildURL(String id, String size) { - var formattedSize = size; - var hash = idToPath(id, 4, 2); - var formattedId = hash + id + ".jp2"; - if (!Objects.equals(size, FULL_SIZE)) { - // pixel length should be in !123,123 format - formattedSize = "!" + size + "," + size; - } - return iiifBasePath + formattedId + "/full/" + formattedSize + "/0/default.jpg"; - } + /** * Determines size based on original dimensions of requested file, unless requested size is full size. diff --git a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/IiifV3ManifestService.java b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/IiifV3ManifestService.java new file mode 100644 index 0000000000..fc14a5f213 --- /dev/null +++ b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/IiifV3ManifestService.java @@ -0,0 +1,241 @@ +package edu.unc.lib.boxc.web.services.processing; + +import edu.unc.lib.boxc.auth.api.Permission; +import edu.unc.lib.boxc.auth.api.models.AgentPrincipals; +import edu.unc.lib.boxc.auth.api.services.AccessControlService; +import edu.unc.lib.boxc.auth.api.services.DatastreamPermissionUtil; +import edu.unc.lib.boxc.common.util.URIUtil; +import edu.unc.lib.boxc.model.api.DatastreamType; +import edu.unc.lib.boxc.model.api.ResourceType; +import edu.unc.lib.boxc.model.api.exceptions.NotFoundException; +import edu.unc.lib.boxc.model.api.ids.PID; +import edu.unc.lib.boxc.search.api.models.ContentObjectRecord; +import edu.unc.lib.boxc.search.api.models.Datastream; +import edu.unc.lib.boxc.web.common.services.AccessCopiesService; +import info.freelibrary.iiif.presentation.v3.AnnotationPage; +import info.freelibrary.iiif.presentation.v3.Canvas; +import info.freelibrary.iiif.presentation.v3.ImageContent; +import info.freelibrary.iiif.presentation.v3.Manifest; +import info.freelibrary.iiif.presentation.v3.PaintingAnnotation; +import info.freelibrary.iiif.presentation.v3.properties.Label; +import info.freelibrary.iiif.presentation.v3.properties.Metadata; +import info.freelibrary.iiif.presentation.v3.properties.RequiredStatement; +import info.freelibrary.iiif.presentation.v3.services.ImageService3; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.CollectionUtils; + +import java.util.ArrayList; +import java.util.List; + +import static edu.unc.lib.boxc.model.api.DatastreamType.JP2_ACCESS_COPY; + +/** + * Service for generating iiif v3 manifests for repository object + * @author bbpennel + */ +public class IiifV3ManifestService { + private static final Logger log = LoggerFactory.getLogger(IiifV3ManifestService.class); + private AccessCopiesService accessCopiesService; + private AccessControlService accessControlService; + private String baseIiifv3Path; + private String baseAccessPath; + private String baseServicesApiPath; + + /** + * Constructs a manifest record for the object identified by the provided PID + * @param pid + * @param agent + * @return + */ + public Manifest buildManifest(PID pid, AgentPrincipals agent) { + assertHasAccess(pid, agent); + var contentObjs = accessCopiesService.listViewableFiles(pid, agent.getPrincipals()); + if (contentObjs.size() == 0) { + throw new NotFoundException("No objects were found for inclusion in manifest for object " + pid.getId()); + } + log.debug("Constructing manifest for {} containing {} items", pid.getId(), contentObjs.size()); + ContentObjectRecord rootObj = contentObjs.get(0); + var manifest = new Manifest(getManifestPath(rootObj), new Label(getTitle(rootObj))); + manifest.setMetadata(constructMetadataSection(rootObj)); + addAttribution(manifest, rootObj); + + addCanvasItems(manifest, contentObjs); + + return manifest; + } + + private List constructMetadataSection(ContentObjectRecord rootObj) { + var metadataList = new ArrayList(); + String abstractText = rootObj.getAbstractText(); + if (abstractText != null) { + metadataList.add(new Metadata("description", abstractText)); + } + addMultiValuedMetadataField(metadataList, "Creators", rootObj.getCreator()); + addMultiValuedMetadataField(metadataList, "Subjects", rootObj.getSubject()); + addMultiValuedMetadataField(metadataList, "Languages", rootObj.getLanguage()); + metadataList.add(new Metadata("", "View full record")); + return metadataList; + } + + private void addMultiValuedMetadataField(List metadataList, String fieldName, List values) { + if (!CollectionUtils.isEmpty(values)) { + metadataList.add(new Metadata(fieldName, String.join(", ", values))); + } + } + + private String getTitle(ContentObjectRecord contentObj) { + String title = contentObj.getTitle(); + return (title != null) ? title : ""; + } + + private void addAttribution(Manifest manifest, ContentObjectRecord rootObj) { + String attribution = "University of North Carolina Libraries, Digital Collections Repository"; + String collection = rootObj.getParentCollectionName(); + if (collection != null) { + attribution += " - Part of " + collection; + } + manifest.setRequiredStatement(new RequiredStatement("Attribution", attribution)); + } + + /** + * Add canvas items for each record in the set being processed + * @param manifest + * @param contentObjs + */ + private void addCanvasItems(Manifest manifest, List contentObjs) { + var canvases = new ArrayList(); + for (ContentObjectRecord contentObj : contentObjs) { + // Add canvases for any records with displayable content + if (hasViewableContent(contentObj)) { + canvases.add(constructCanvasSection(contentObj)); + } + } + manifest.setCanvases(canvases); + } + + /** + * Constructs a standalone canvas document for the requested object + * @param pid + * @param agent + * @return + */ + public Canvas buildCanvas(PID pid, AgentPrincipals agent) { + assertHasAccess(pid, agent); + var contentObjs = accessCopiesService.listViewableFiles(pid, agent.getPrincipals()); + ContentObjectRecord rootObj = contentObjs.get(0); + return constructCanvasSection(rootObj); + } + + /** + * Constructs a canvas record for the provided object + * @param contentObj + * @return + */ + private Canvas constructCanvasSection(ContentObjectRecord contentObj) { + String title = getTitle(contentObj); + String uuid = contentObj.getId(); + + var canvas = new Canvas(getCanvasPath(contentObj), title); + + // Set up thumbnail for the current item + var thumbnail = new ImageContent(makeThumbnailUrl(uuid)); + canvas.setThumbnails(thumbnail); + + // Children of canvas are AnnotationPages + var annoPage = new AnnotationPage(getAnnotationPagePath(contentObj)); + canvas.setPaintingPages(annoPage); + + // Child of the AnnotationPage is an Annotation, specifically a PaintingAnnotation in this case + var paintingAnno = new PaintingAnnotation(getAnnotationPath(contentObj), canvas); + annoPage.addAnnotations(paintingAnno); + + // Child of the Annotation is the content resource, which is an ImageContent + var imageContent = new ImageContent(getImagePath(contentObj)); + imageContent.setFormat("image/jpeg"); + paintingAnno.getBodies().add(imageContent); + + // Child of the content resource is an ImageService + var imageService = new ImageService3(ImageService3.Profile.LEVEL_TWO, getServicePath(contentObj)); + imageContent.setServices(imageService); + + // Set the dimensions of this item on appropriate elements + assignDimensions(contentObj, canvas, imageContent); + + return canvas; + } + + private void assignDimensions(ContentObjectRecord contentObj, Canvas canvas, ImageContent imageContent) { + Datastream fileDs = contentObj.getDatastreamObject(DatastreamType.ORIGINAL_FILE.getId()); + String extent = fileDs.getExtent(); + if (extent != null && !extent.equals("")) { + String[] imgDimensions = extent.split("x"); + var width = Integer.parseInt(imgDimensions[1]); // in the datastream, the width is second + var height = Integer.parseInt(imgDimensions[0]); + canvas.setWidthHeight(width, height); // Dimensions for the canvas + imageContent.setWidthHeight(width, height); // Dimensions for the actual image + } + } + + private void assertHasAccess(PID pid, AgentPrincipals agent) { + Permission permission = DatastreamPermissionUtil.getPermissionForDatastream(JP2_ACCESS_COPY); + + log.debug("Checking if user {} has access to {} belonging to object {}.", + agent.getUsername(), JP2_ACCESS_COPY, pid); + accessControlService.assertHasAccess(pid, agent.getPrincipals(), permission); + } + + private String getManifestPath(ContentObjectRecord contentObj) { + return URIUtil.join(baseIiifv3Path, contentObj.getId(), "manifest"); + } + + private String getCanvasPath(ContentObjectRecord contentObj) { + return URIUtil.join(baseIiifv3Path, contentObj.getId(), "canvas"); + } + + private String getAnnotationPagePath(ContentObjectRecord contentObj) { + return URIUtil.join(baseIiifv3Path, contentObj.getId(), "page", "1"); + } + + private String getAnnotationPath(ContentObjectRecord contentObj) { + return URIUtil.join(baseIiifv3Path, contentObj.getId(), "annotation", "1"); + } + + private String getImagePath(ContentObjectRecord contentObj) { + return URIUtil.join(baseIiifv3Path, contentObj.getId(), "full", "max", "0", "default.jpg"); + } + + private String getServicePath(ContentObjectRecord contentObj) { + return URIUtil.join(baseIiifv3Path, contentObj.getId()); + } + + private String makeThumbnailUrl(String id) { + return URIUtil.join(baseServicesApiPath, "thumb", id, "large"); + } + + private boolean hasViewableContent(ContentObjectRecord contentObj) { + var datastream = contentObj.getDatastreamObject(DatastreamType.JP2_ACCESS_COPY.getId()); + return datastream != null && contentObj.getResourceType().equals(ResourceType.File.name()); + } + + public void setAccessCopiesService(AccessCopiesService accessCopiesService) { + this.accessCopiesService = accessCopiesService; + } + + public void setAccessControlService(AccessControlService accessControlService) { + this.accessControlService = accessControlService; + } + + public void setBaseIiifv3Path(String baseIiifv3Path) { + this.baseIiifv3Path = baseIiifv3Path; + } + + public void setBaseAccessPath(String baseAccessPath) { + this.baseAccessPath = baseAccessPath; + } + + public void setBaseServicesApiPath(String baseServicesApiPath) { + this.baseServicesApiPath = baseServicesApiPath; + } +} diff --git a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/ImageServerProxyService.java b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/ImageServerProxyService.java new file mode 100644 index 0000000000..3bb0d4127e --- /dev/null +++ b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/ImageServerProxyService.java @@ -0,0 +1,109 @@ +package edu.unc.lib.boxc.web.services.processing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import edu.unc.lib.boxc.common.util.URIUtil; +import org.apache.http.HttpStatus; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.conn.HttpClientConnectionManager; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; + +import static edu.unc.lib.boxc.web.services.utils.ImageServerUtil.getImageServerEncodedId; + +/** + * Generates request, connects to, and streams the output from the image Server Proxy. + * @author bbpennel, snluong + */ +public class ImageServerProxyService { + private static final Logger LOG = LoggerFactory.getLogger(ImageServerProxyService.class); + private CloseableHttpClient httpClient; + private String imageServerProxyBasePath; + private String baseIiifv3Path; + + public void setHttpClientConnectionManager(HttpClientConnectionManager manager) { + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(2000) + .setConnectionRequestTimeout(5000) + .build(); + + this.httpClient = HttpClients.custom() + .setConnectionManager(manager) + .setDefaultRequestConfig(requestConfig) + .build(); + } + + /** + * Gets metadata from the IIIF V3 image server about the requested ID + * @param id ID of the requested object + */ + public JsonNode getMetadata(String id) throws IOException { + var url = URIUtil.join(getImageServerProxyBasePath(), getImageServerEncodedId(id), "/info.json"); + HttpGet method = new HttpGet(url); + try (CloseableHttpResponse httpResp = httpClient.execute(method)) { + var statusCode = httpResp.getStatusLine().getStatusCode(); + if (statusCode == HttpStatus.SC_OK) { + var mapper = new ObjectMapper(); + var respData = mapper.readTree(httpResp.getEntity().getContent()); + ((ObjectNode) respData).put("id", URIUtil.join(baseIiifv3Path, id)); + return respData; + } + } finally { + method.releaseConnection(); + } + + LOG.error("Unexpected failure while getting image server proxy path {}", method); + return null; + } + + + /** + * Gets the datastream from the IIIF V3 image server for the requested ID + * @param id ID of the requested object + * @param region region of the image + * @param size pixel size of the image, or max + * @param rotation degree of rotation + * @param quality quality of image + * @param format format like png or jpg + */ + public ResponseEntity streamJP2(String id, String region, String size, String rotation, + String quality, String format) throws IOException { + + String path = URIUtil.join(getImageServerProxyBasePath(), getImageServerEncodedId(id), + region, size, rotation, quality); + path += "." + format; + + InputStream input = new URL(path).openStream(); + InputStreamResource resource = new InputStreamResource(input); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "inline") + .contentType(MediaType.IMAGE_JPEG) + .body(resource); + } + + public void setImageServerProxyBasePath(String fullPath) { + this.imageServerProxyBasePath = fullPath; + } + + public String getImageServerProxyBasePath() { + return imageServerProxyBasePath; + } + + public void setBaseIiifv3Path(String baseIiifv3Path) { + this.baseIiifv3Path = baseIiifv3Path; + } +} diff --git a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/DownloadImageController.java b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/DownloadImageController.java index e220dff254..d478a3e003 100644 --- a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/DownloadImageController.java +++ b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/DownloadImageController.java @@ -9,6 +9,7 @@ import edu.unc.lib.boxc.search.api.requests.SimpleIdRequest; import edu.unc.lib.boxc.web.common.services.SolrQueryLayerService; import edu.unc.lib.boxc.web.services.processing.DownloadImageService; +import edu.unc.lib.boxc.web.services.utils.ImageServerUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -50,7 +51,7 @@ public ResponseEntity getImage(@PathVariable("pid") String var contentObjectRecord = solrSearchService.getObjectById(new SimpleIdRequest(pid, principals)); String validatedSize = downloadImageService.getSize(contentObjectRecord, size); - if (Objects.equals(validatedSize, DownloadImageService.FULL_SIZE)) { + if (Objects.equals(validatedSize, ImageServerUtil.FULL_SIZE)) { aclService.assertHasAccess("Insufficient permissions to download full size copy for " + pidString, pid, principals, Permission.viewOriginal); } diff --git a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/IiifV3ManifestController.java b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/IiifV3ManifestController.java new file mode 100644 index 0000000000..167e7c3862 --- /dev/null +++ b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/IiifV3ManifestController.java @@ -0,0 +1,65 @@ +package edu.unc.lib.boxc.web.services.rest; + +import edu.unc.lib.boxc.auth.fcrepo.models.AgentPrincipalsImpl; +import edu.unc.lib.boxc.model.api.ids.PID; +import edu.unc.lib.boxc.model.fcrepo.ids.PIDs; +import edu.unc.lib.boxc.web.services.processing.IiifV3ManifestService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ResponseBody; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +/** + * Controller which handles iiif v3 requests + * + * @author bbpennel + */ +@Controller +public class IiifV3ManifestController { + private static final Logger log = LoggerFactory.getLogger(IiifV3ManifestController.class); + + @Autowired + private IiifV3ManifestService manifestService; + + /** + * Handles requests for IIIF v3 manifests + * @param id + * @return Response containing the manifest + */ + @CrossOrigin + @GetMapping(value = "/iiif/v3/{id}/manifest", produces = APPLICATION_JSON_VALUE) + @ResponseBody + public ResponseEntity getManifest(@PathVariable("id") String id) { + PID pid = PIDs.get(id); + log.debug("Getting manifest for {}", pid.getId()); + var manifest = manifestService.buildManifest(pid, AgentPrincipalsImpl.createFromThread()); + return new ResponseEntity<>(manifest, HttpStatus.OK); + } + + /** + * Handles requests for IIIF v3 manifests + * @param id + * @return Response containing the manifest + */ + @CrossOrigin + @GetMapping(value = "/iiif/v3/{id}/canvas", produces = APPLICATION_JSON_VALUE) + @ResponseBody + public ResponseEntity getCanvas(@PathVariable("id") String id) { + PID pid = PIDs.get(id); + log.debug("Getting canvas for {}", pid.getId()); + var manifest = manifestService.buildCanvas(pid, AgentPrincipalsImpl.createFromThread()); + return new ResponseEntity<>(manifest, HttpStatus.OK); + } + + public void setManifestService(IiifV3ManifestService manifestService) { + this.manifestService = manifestService; + } +} diff --git a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/ImageServerProxyController.java b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/ImageServerProxyController.java new file mode 100644 index 0000000000..1df996ddfb --- /dev/null +++ b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/rest/ImageServerProxyController.java @@ -0,0 +1,89 @@ +package edu.unc.lib.boxc.web.services.rest; + +import edu.unc.lib.boxc.auth.api.Permission; +import edu.unc.lib.boxc.auth.api.models.AccessGroupSet; +import edu.unc.lib.boxc.auth.api.services.AccessControlService; +import edu.unc.lib.boxc.model.api.ids.PID; +import edu.unc.lib.boxc.model.fcrepo.ids.PIDs; +import edu.unc.lib.boxc.web.services.processing.ImageServerProxyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.CrossOrigin; +import java.io.IOException; + +import static edu.unc.lib.boxc.auth.fcrepo.services.GroupsThreadStore.getAgentPrincipals; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +/** + * Controller that handles IIIF V3 requests to the image server + * @author snluong + */ +@Controller +public class ImageServerProxyController { + private static final Logger LOG = LoggerFactory.getLogger(ImageServerProxyController.class); + @Autowired + private ImageServerProxyService imageServerProxyService; + @Autowired + private AccessControlService accessControlService; + + /** + * Handles requests for individual region tiles. + * @param id + * @param region + * @param size pixel size or max for full size + * @param rotation + * @param qualityFormat + */ + @CrossOrigin + @GetMapping("/iiif/v3/{id}/{region}/{size}/{rotation}/{qualityFormat:.+}") + public ResponseEntity getRegion(@PathVariable("id") String id, + @PathVariable("region") String region, + @PathVariable("size") String size, @PathVariable("rotation") String rotation, + @PathVariable("qualityFormat") String qualityFormat) { + + PID pid = PIDs.get(id); + // Check if the user is allowed to view this object + AccessGroupSet principals = getAgentPrincipals().getPrincipals(); + accessControlService.assertHasAccess("Insufficient permissions to get a region for " + id, + pid, principals, Permission.viewAccessCopies); + + try { + String[] qualityFormatArray = qualityFormat.split("\\."); + String quality = qualityFormatArray[0]; + String format = qualityFormatArray[1]; + return imageServerProxyService.streamJP2(id, region, size, rotation, quality, format); + } catch (IOException e) { + LOG.error("Error retrieving streaming JP2 content for {}", id, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Handles requests for jp2 metadata + * @param id ID of the object + */ + @CrossOrigin + @GetMapping(value ="/iiif/v3/{id}/info.json", produces = APPLICATION_JSON_VALUE) + public ResponseEntity getMetadata(@PathVariable("id") String id) { + PID pid = PIDs.get(id); + // Check if the user is allowed to view this object + AccessGroupSet principals = getAgentPrincipals().getPrincipals(); + accessControlService.assertHasAccess("Insufficient permissions to get metadata for " + id, + pid, principals, Permission.viewAccessCopies); + + try { + var metadata = imageServerProxyService.getMetadata(id); + return new ResponseEntity<>(metadata, HttpStatus.OK); + } catch (IOException e) { + LOG.error("Error retrieving JP2 metadata content for {}", id, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/utils/ImageServerUtil.java b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/utils/ImageServerUtil.java new file mode 100644 index 0000000000..4dcdf6c49d --- /dev/null +++ b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/utils/ImageServerUtil.java @@ -0,0 +1,45 @@ +package edu.unc.lib.boxc.web.services.utils; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import static edu.unc.lib.boxc.model.fcrepo.ids.RepositoryPaths.idToPath; + +/** + * @author snluong + */ +public class ImageServerUtil { + public static final String FULL_SIZE = "max"; + + private ImageServerUtil() { + } + + /** + * Returns the object ID in proper encoded format with .jp2 extension + * @param id + * @return + */ + public static String getImageServerEncodedId(String id) { + var idPathEncoded = URLEncoder.encode(idToPath(id, 4, 2), StandardCharsets.UTF_8); + var idEncoded = URLEncoder.encode(id, StandardCharsets.UTF_8); + return idPathEncoded + idEncoded + ".jp2"; + } + + /** + * A method that builds the IIIF URL based on an assumption of full region, 0 rotation, and default quality. + * @param basePath iiif V3 base path + * @param id the UUID of the file + * @param size a string which is either "max" for full size or a pixel length like "1200" + * @return a string which is the URL to request the IIIF server for the image + */ + public static String buildURL(String basePath, String id, String size) { + var formattedSize = size; + var formattedId = getImageServerEncodedId(id); + if (!Objects.equals(size, FULL_SIZE)) { + // pixel length should be in !123,123 format + formattedSize = "!" + size + "," + size; + } + return basePath + formattedId + "/full/" + formattedSize + "/0/default.jpg"; + } +} diff --git a/web-services-app/src/main/webapp/WEB-INF/service-context.xml b/web-services-app/src/main/webapp/WEB-INF/service-context.xml index f98c29b340..fe76f8785a 100644 --- a/web-services-app/src/main/webapp/WEB-INF/service-context.xml +++ b/web-services-app/src/main/webapp/WEB-INF/service-context.xml @@ -432,6 +432,20 @@ + + + + + + + + + + + + + + diff --git a/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/processing/IiifV3ManifestServiceTest.java b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/processing/IiifV3ManifestServiceTest.java new file mode 100644 index 0000000000..6e090003c4 --- /dev/null +++ b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/processing/IiifV3ManifestServiceTest.java @@ -0,0 +1,224 @@ +package edu.unc.lib.boxc.web.services.processing; + +import edu.unc.lib.boxc.auth.api.Permission; +import edu.unc.lib.boxc.auth.api.exceptions.AccessRestrictionException; +import edu.unc.lib.boxc.auth.api.models.AccessGroupSet; +import edu.unc.lib.boxc.auth.api.models.AgentPrincipals; +import edu.unc.lib.boxc.auth.api.services.AccessControlService; +import edu.unc.lib.boxc.model.api.ResourceType; +import edu.unc.lib.boxc.model.api.exceptions.NotFoundException; +import edu.unc.lib.boxc.model.api.ids.PID; +import edu.unc.lib.boxc.model.fcrepo.ids.PIDs; +import edu.unc.lib.boxc.search.api.models.ContentObjectRecord; +import edu.unc.lib.boxc.search.solr.models.ContentObjectSolrRecord; +import edu.unc.lib.boxc.search.solr.models.DatastreamImpl; +import edu.unc.lib.boxc.web.common.services.AccessCopiesService; +import info.freelibrary.iiif.presentation.v3.Canvas; +import info.freelibrary.iiif.presentation.v3.ImageContent; +import info.freelibrary.iiif.presentation.v3.services.ImageService3; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.util.Arrays; + +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.openMocks; + +/** + * @author bbpennel + */ +public class IiifV3ManifestServiceTest { + private static final String IIIF_BASE = "http://example.com/iiif/v3/"; + private static final String SERVICES_BASE = "http://example.com/services/api/"; + private static final String ACCESS_BASE = "http://example.com/"; + private static final String WORK_ID = "5d72b84a-983c-4a45-8caa-dc9857987da2"; + private static final String FILE1_ID = "faffb3e1-85fc-451f-9075-c60fc7584c7b"; + private static final String FILE2_ID = "b6c51e59-d931-41d6-ba26-ec54ba9b2ef5"; + private static final String COLL_ID = "fdce64cb-6a6f-43bb-8ed2-58f3b60148bf"; + private static final PID WORK_PID = PIDs.get(WORK_ID); + + @Mock + private AccessCopiesService accessCopiesService; + @Mock + private AccessControlService accessControlService; + @Mock + private AgentPrincipals agent; + @Mock + private AccessGroupSet principals; + private AutoCloseable closeable; + + private ContentObjectSolrRecord workObj; + + private IiifV3ManifestService manifestService; + + @BeforeEach + public void setup() { + closeable = openMocks(this); + manifestService = new IiifV3ManifestService(); + manifestService.setAccessCopiesService(accessCopiesService); + manifestService.setAccessControlService(accessControlService); + manifestService.setBaseIiifv3Path(IIIF_BASE); + manifestService.setBaseServicesApiPath(SERVICES_BASE); + manifestService.setBaseAccessPath(ACCESS_BASE); + + when(agent.getPrincipals()).thenReturn(principals); + + workObj = new ContentObjectSolrRecord(); + workObj.setId(WORK_ID); + workObj.setResourceType(ResourceType.Work.name()); + workObj.setTitle("Test Work"); + } + + @AfterEach + void closeService() throws Exception { + closeable.close(); + } + + private ContentObjectRecord createFileRecord(String id) { + var fileObj = new ContentObjectSolrRecord(); + fileObj.setId(id); + fileObj.setResourceType(ResourceType.File.name()); + fileObj.setTitle("File Object " + id); + var originalDs = new DatastreamImpl("original_file|image/jpeg|image.jpg|jpg|0|||240x750"); + var jp2Ds = new DatastreamImpl("jp2|image/jp2|image.jp2|jp2|0|||"); + fileObj.setDatastream(Arrays.asList(originalDs.toString(), jp2Ds.toString())); + return fileObj; + } + + @Test + public void buildManifestNoViewableFilesTest() { + when(accessCopiesService.listViewableFiles(WORK_PID, principals)).thenReturn(Arrays.asList()); + assertThrows(NotFoundException.class, () -> { + manifestService.buildManifest(WORK_PID, agent); + }); + } + + @Test + public void buildManifestNoAccessTest() { + doThrow(new AccessRestrictionException()).when(accessControlService) + .assertHasAccess(eq(WORK_PID), any(), eq(Permission.viewAccessCopies)); + assertThrows(AccessRestrictionException.class, () -> { + manifestService.buildManifest(WORK_PID, agent); + }); + } + + @Test + public void buildManifestWorkWithoutViewableFilesTest() { + when(accessCopiesService.listViewableFiles(WORK_PID, principals)).thenReturn(Arrays.asList(workObj)); + + var manifest = manifestService.buildManifest(WORK_PID, agent); + assertEquals("Test Work", manifest.getLabel().getString()); + assertEquals("http://example.com/iiif/v3/5d72b84a-983c-4a45-8caa-dc9857987da2/manifest", manifest.getID().toString()); + assertEquals("View full record", + manifest.getMetadata().get(0).getValue().getString()); + assertTrue(manifest.getCanvases().isEmpty()); + } + + @Test + public void buildManifestWorkWithViewableFilesTest() { + var fileObj1 = createFileRecord(FILE1_ID); + var fileObj2 = createFileRecord(FILE2_ID); + when(accessCopiesService.listViewableFiles(WORK_PID, principals)).thenReturn(Arrays.asList(workObj, fileObj1, fileObj2)); + + var manifest = manifestService.buildManifest(WORK_PID, agent); + assertEquals("Test Work", manifest.getLabel().getString()); + assertEquals("http://example.com/iiif/v3/5d72b84a-983c-4a45-8caa-dc9857987da2/manifest", + manifest.getID().toString()); + var canvases = manifest.getCanvases(); + assertEquals(2, canvases.size()); + assertFileCanvasPopulated(canvases.get(0), FILE1_ID); + assertFileCanvasPopulated(canvases.get(1), FILE2_ID); + } + + @Test + public void buildManifestViewableFileTest() { + var fileObj1 = createFileRecord(FILE1_ID); + var filePid = PIDs.get(FILE1_ID); + when(accessCopiesService.listViewableFiles(filePid, principals)).thenReturn(Arrays.asList(fileObj1)); + + var manifest = manifestService.buildManifest(filePid, agent); + assertEquals("File Object faffb3e1-85fc-451f-9075-c60fc7584c7b", manifest.getLabel().getString()); + assertEquals("http://example.com/iiif/v3/faffb3e1-85fc-451f-9075-c60fc7584c7b/manifest", + manifest.getID().toString()); + var canvases = manifest.getCanvases(); + assertEquals(1, canvases.size()); + assertFileCanvasPopulated(canvases.get(0), FILE1_ID); + + assertEquals("View full record", + manifest.getMetadata().get(0).getValue().getString()); + } + + @Test + public void buildCanvasViewableFileTest() { + var fileObj1 = createFileRecord(FILE1_ID); + var filePid = PIDs.get(FILE1_ID); + when(accessCopiesService.listViewableFiles(filePid, principals)).thenReturn(Arrays.asList(fileObj1)); + + var canvas = manifestService.buildCanvas(filePid, agent); + assertFileCanvasPopulated(canvas, FILE1_ID); + } + + private void assertFileCanvasPopulated(Canvas fileCanvas, String expectedId) { + assertEquals("http://example.com/iiif/v3/" + expectedId + "/canvas", + fileCanvas.getID().toString()); + assertEquals(240, fileCanvas.getHeight()); + assertEquals(750, fileCanvas.getWidth()); + assertEquals("http://example.com/services/api/thumb/" + expectedId + "/large", + fileCanvas.getThumbnails().get(0).getID().toString()); + var annoPage = fileCanvas.getPaintingPages().get(0); + var annotation = annoPage.getAnnotations().get(0); + assertEquals("painting", annotation.getMotivation()); + + var imageContent = (ImageContent) annotation.getBodies().get(0); + assertEquals(240, imageContent.getHeight()); + assertEquals(750, imageContent.getWidth()); + assertEquals("image/jpeg", imageContent.getFormat().get().toString()); + var imageService = (ImageService3) imageContent.getServices().get(0); + assertEquals("http://example.com/iiif/v3/" + expectedId, imageService.getID().toString()); + assertEquals("level2", imageService.getProfile().get().string()); + } + + @Test + public void buildManifestWorkWithMetadataTest() { + workObj.setSubject(Arrays.asList("Images", "Transformation")); + workObj.setAbstractText("This is a test work"); + workObj.setCreator(Arrays.asList("Boxy", "Boxc")); + workObj.setLanguage(Arrays.asList("English", "Spanish")); + workObj.setParentCollection("Image Collection|" + COLL_ID); + when(accessCopiesService.listViewableFiles(WORK_PID, principals)).thenReturn(Arrays.asList(workObj)); + + var manifest = manifestService.buildManifest(WORK_PID, agent); + assertEquals("http://example.com/iiif/v3/5d72b84a-983c-4a45-8caa-dc9857987da2/manifest", manifest.getID().toString()); + assertEquals("Test Work", manifest.getLabel().getString()); + assertEquals("University of North Carolina Libraries, Digital Collections Repository - Part of Image Collection", + manifest.getRequiredStatement().getValue().getString()); + var abstractMd = manifest.getMetadata().get(0); + assertEquals("description", abstractMd.getLabel().getString()); + assertEquals("This is a test work", abstractMd.getValue().getString()); + + var creatorsMd = manifest.getMetadata().get(1); + assertEquals("Creators", creatorsMd.getLabel().getString()); + assertEquals("Boxy, Boxc", creatorsMd.getValue().getString()); + + var subjectsMd = manifest.getMetadata().get(2); + assertEquals("Subjects", subjectsMd.getLabel().getString()); + assertEquals("Images, Transformation", subjectsMd.getValue().getString()); + + var languagesMd = manifest.getMetadata().get(3); + assertEquals("Languages", languagesMd.getLabel().getString()); + assertEquals("English, Spanish", languagesMd.getValue().getString()); + + var recordLinkMd = manifest.getMetadata().get(4); + assertEquals("", recordLinkMd.getLabel().getString()); + assertEquals("View full record", + recordLinkMd.getValue().getString()); + } +} diff --git a/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/DownloadImageControllerIT.java b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/DownloadImageControllerIT.java index 3eb0152ea6..075a05b665 100644 --- a/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/DownloadImageControllerIT.java +++ b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/DownloadImageControllerIT.java @@ -14,7 +14,9 @@ import edu.unc.lib.boxc.search.solr.models.ContentObjectSolrRecord; import edu.unc.lib.boxc.web.common.services.SolrQueryLayerService; import edu.unc.lib.boxc.web.services.processing.DownloadImageService; +import edu.unc.lib.boxc.web.services.processing.ImageServerProxyService; import edu.unc.lib.boxc.web.services.rest.modify.AbstractAPIIT; +import edu.unc.lib.boxc.web.services.utils.ImageServerUtil; import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -64,7 +66,7 @@ public class DownloadImageControllerIT extends AbstractAPIIT { public void testGetImageAtFullSize() throws Exception { var pid = makePid(); var pidString = pid.getId(); - var formattedPid = idToPath(pidString, 4, 2) + pidString + ".jp2"; + var formattedPid = ImageServerUtil.getImageServerEncodedId(pidString); var filename = "bunny.jpg"; ContentObjectSolrRecord contentObjectSolrRecord = mock(ContentObjectSolrRecord.class); Datastream originalDatastream = mock(Datastream.class); @@ -77,25 +79,25 @@ public void testGetImageAtFullSize() throws Exception { when(contentObjectSolrRecord.getPid()).thenReturn(pid); - stubFor(WireMock.get(urlMatching("/" + formattedPid + "/full/full/0/default.jpg")) + stubFor(WireMock.get(urlMatching("/" + formattedPid + "/full/max/0/default.jpg")) .willReturn(aResponse() .withStatus(HttpStatus.OK.value()) .withBodyFile(filename) .withHeader("Content-Type", "image/jpeg"))); - MvcResult result = mvc.perform(get("/downloadImage/" + pidString + "/full")) + MvcResult result = mvc.perform(get("/downloadImage/" + pidString + "/max")) .andExpect(status().is2xxSuccessful()) .andReturn(); var response = result.getResponse(); - assertEquals("attachment; filename=bunny_full.jpg", response.getHeader(CONTENT_DISPOSITION)); + assertEquals("attachment; filename=bunny_max.jpg", response.getHeader(CONTENT_DISPOSITION)); } @Test public void testGetImageAtPixelSizeSmallerThanFull() throws Exception { var pid = makePid(); var pidString = pid.getId(); - var formattedPid = idToPath(pidString, 4, 2) + pidString + ".jp2"; + var formattedPid = ImageServerUtil.getImageServerEncodedId(pidString); var filename = "bunny.jpg"; ContentObjectSolrRecord contentObjectSolrRecord = mock(ContentObjectSolrRecord.class); Datastream originalDatastream = mock(Datastream.class); @@ -128,13 +130,13 @@ public void testGetImageAtPixelSizeSmallerThanFull() throws Exception { public void testGetImageAtPixelSizeBiggerThanFull() throws Exception { var pid = makePid(); var pidString = pid.getId(); - var formattedPid = idToPath(pidString, 4, 2) + pidString + ".jp2"; + var formattedPid = ImageServerUtil.getImageServerEncodedId(pidString); var filename = "bunny.jpg"; ContentObjectSolrRecord contentObjectSolrRecord = mock(ContentObjectSolrRecord.class); Datastream originalDatastream = mock(Datastream.class); Datastream jp2Datastream = mock(Datastream.class); - stubFor(WireMock.get(urlMatching("/" + formattedPid + "/full/full/0/default.jpg")) + stubFor(WireMock.get(urlMatching("/" + formattedPid + "/full/max/0/default.jpg")) .willReturn(aResponse() .withStatus(HttpStatus.OK.value()) .withBodyFile(filename) @@ -153,7 +155,7 @@ public void testGetImageAtPixelSizeBiggerThanFull() throws Exception { var response = result.getResponse(); - assertEquals("attachment; filename=bunny_full.jpg", response.getHeader(CONTENT_DISPOSITION)); + assertEquals("attachment; filename=bunny_max.jpg", response.getHeader(CONTENT_DISPOSITION)); assertCorrectImageReturned(response); } @@ -165,7 +167,7 @@ public void testFullSizeAccessImageNoFullSizePermissions() throws Exception { doThrow(new AccessRestrictionException()).when(accessControlService) .assertHasAccess(anyString(), eq(filePid), any(AccessGroupSetImpl.class), eq(viewOriginal)); - MvcResult result = mvc.perform(get("/downloadImage/" + filePid.getId() + "/full")) + MvcResult result = mvc.perform(get("/downloadImage/" + filePid.getId() + "/max")) .andExpect(status().isForbidden()) .andReturn(); @@ -276,7 +278,7 @@ public void testGetImageNoJP2() throws Exception { ContentObjectSolrRecord contentObjectSolrRecord = mock(ContentObjectSolrRecord.class); when(solrSearchService.getObjectById(any(SimpleIdRequest.class))).thenReturn(contentObjectSolrRecord); - mvc.perform(get("/downloadImage/" + filePid.getId() + "/full")) + mvc.perform(get("/downloadImage/" + filePid.getId() + "/max")) .andExpect(status().is4xxClientError()) .andReturn(); } diff --git a/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/IiifV3ManifestControllerTest.java b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/IiifV3ManifestControllerTest.java new file mode 100644 index 0000000000..76b2111e4d --- /dev/null +++ b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/IiifV3ManifestControllerTest.java @@ -0,0 +1,144 @@ +package edu.unc.lib.boxc.web.services.rest; + +import edu.unc.lib.boxc.auth.api.Permission; +import edu.unc.lib.boxc.auth.api.exceptions.AccessRestrictionException; +import edu.unc.lib.boxc.auth.api.models.AccessGroupSet; +import edu.unc.lib.boxc.auth.api.services.AccessControlService; +import edu.unc.lib.boxc.auth.fcrepo.models.AccessGroupSetImpl; +import edu.unc.lib.boxc.auth.fcrepo.services.GroupsThreadStore; +import edu.unc.lib.boxc.model.api.ResourceType; +import edu.unc.lib.boxc.model.api.ids.PID; +import edu.unc.lib.boxc.model.fcrepo.ids.PIDs; +import edu.unc.lib.boxc.search.solr.models.ContentObjectSolrRecord; +import edu.unc.lib.boxc.search.solr.models.DatastreamImpl; +import edu.unc.lib.boxc.web.common.services.AccessCopiesService; +import edu.unc.lib.boxc.web.services.processing.IiifV3ManifestService; +import edu.unc.lib.boxc.web.services.rest.exceptions.RestResponseEntityExceptionHandler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.openMocks; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author bbpennel + */ +public class IiifV3ManifestControllerTest { + private static final String IIIF_BASE = "http://example.com/iiif/v3/"; + private static final String SERVICES_BASE = "http://example.com/services/"; + private static final String ACCESS_BASE = "http://example.com/"; + + private static final String OBJECT_ID = "f277bb38-272c-471c-a28a-9887a1328a1f"; + private static final PID OBJECT_PID = PIDs.get(OBJECT_ID); + private final static String USERNAME = "test_user"; + private final static AccessGroupSet GROUPS = new AccessGroupSetImpl("adminGroup"); + + @InjectMocks + private IiifV3ManifestController manifestController; + + @Mock + private AccessControlService accessControlService; + + @Mock + private AccessCopiesService accessCopiesService; + + private IiifV3ManifestService manifestService; + + private MockMvc mockMvc; + + private AutoCloseable closeable; + + @BeforeEach + public void setup() { + closeable = openMocks(this); + manifestService = new IiifV3ManifestService(); + manifestService.setAccessCopiesService(accessCopiesService); + manifestService.setAccessControlService(accessControlService); + manifestService.setBaseIiifv3Path(IIIF_BASE); + manifestService.setBaseServicesApiPath(SERVICES_BASE); + manifestService.setBaseAccessPath(ACCESS_BASE); + manifestController.setManifestService(manifestService); + mockMvc = MockMvcBuilders.standaloneSetup(manifestController) + .setControllerAdvice(new RestResponseEntityExceptionHandler()) + .build(); + GroupsThreadStore.storeUsername(USERNAME); + GroupsThreadStore.storeGroups(GROUPS); + } + + @AfterEach + void closeService() throws Exception { + closeable.close(); + } + + @Test + public void testGetManifest() throws Exception { + var workObj = new ContentObjectSolrRecord(); + workObj.setId(OBJECT_ID); + workObj.setResourceType(ResourceType.Work.name()); + workObj.setTitle("Test Work"); + when(accessCopiesService.listViewableFiles(eq(OBJECT_PID), any())).thenReturn(Arrays.asList(workObj)); + + var result = mockMvc.perform(get("/iiif/v3/" + OBJECT_ID + "/manifest") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + Map respMap = MvcTestHelpers.getMapFromResponse(result); + assertEquals("Manifest", respMap.get("type")); + assertEquals("http://example.com/iiif/v3/f277bb38-272c-471c-a28a-9887a1328a1f/manifest", respMap.get("id")); + assertEquals("Test Work", ((List) ((Map) respMap.get("label")).get("none")).get(0)); + var metadata = (List) respMap.get("metadata"); + assertFalse(metadata.isEmpty()); + } + + @Test + public void testGetManifestNoAccess() throws Exception { + doThrow(new AccessRestrictionException()).when(accessControlService) + .assertHasAccess(eq(OBJECT_PID), any(), eq(Permission.viewAccessCopies)); + + mockMvc.perform(get("/iiif/v3/" + OBJECT_ID + "/manifest") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + public void testGetCanvas() throws Exception { + var fileObj = new ContentObjectSolrRecord(); + fileObj.setId(OBJECT_ID); + fileObj.setResourceType(ResourceType.File.name()); + fileObj.setTitle("File Object"); + var originalDs = new DatastreamImpl("original_file|image/jpeg|image.jpg|jpg|0|||240x750"); + var jp2Ds = new DatastreamImpl("jp2|image/jp2|image.jp2|jp2|0|||"); + fileObj.setDatastream(Arrays.asList(originalDs.toString(), jp2Ds.toString())); + when(accessCopiesService.listViewableFiles(eq(OBJECT_PID), any())).thenReturn(Arrays.asList(fileObj)); + + var result = mockMvc.perform(get("/iiif/v3/" + OBJECT_ID + "/canvas") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + Map respMap = MvcTestHelpers.getMapFromResponse(result); + assertEquals("Canvas", respMap.get("type")); + assertEquals("http://example.com/iiif/v3/f277bb38-272c-471c-a28a-9887a1328a1f/canvas", respMap.get("id")); + assertEquals(750, respMap.get("width")); + var items = (List) respMap.get("items"); + assertFalse(items.isEmpty()); + } +} diff --git a/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/ImageServerProxyControllerTest.java b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/ImageServerProxyControllerTest.java new file mode 100644 index 0000000000..34c6ddc67f --- /dev/null +++ b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/ImageServerProxyControllerTest.java @@ -0,0 +1,154 @@ +package edu.unc.lib.boxc.web.services.rest; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import edu.unc.lib.boxc.auth.api.exceptions.AccessRestrictionException; +import edu.unc.lib.boxc.auth.api.services.AccessControlService; +import edu.unc.lib.boxc.auth.fcrepo.models.AccessGroupSetImpl; +import edu.unc.lib.boxc.web.services.processing.ImageServerProxyService; +import edu.unc.lib.boxc.web.services.rest.modify.AbstractAPIIT; +import edu.unc.lib.boxc.web.services.utils.ImageServerUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MvcResult; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static edu.unc.lib.boxc.auth.api.Permission.viewAccessCopies; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.MockitoAnnotations.openMocks; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author snluong + */ +@ExtendWith(SpringExtension.class) +@WebAppConfiguration +@ContextConfiguration("/image-server-proxy-servlet.xml") + +@WireMockTest(httpPort = 46887) +public class ImageServerProxyControllerTest extends AbstractAPIIT { + @Autowired + private AccessControlService accessControlService; + @Mock + private ImageServerProxyService imageServerProxyService; + private AutoCloseable closeable; + + @BeforeEach + public void setup() { + closeable = openMocks(this); + } + + @AfterEach + void closeService() throws Exception { + closeable.close(); + } + + @Test + void testGetRegionNoAccess() throws Exception { + var pid = makePid(); + var pidString = pid.getId(); + doThrow(new AccessRestrictionException()).when(accessControlService) + .assertHasAccess(anyString(), eq(pid), any(AccessGroupSetImpl.class), eq(viewAccessCopies)); + + mvc.perform(get("/iiif/v3/" + pidString + "/full/max/0/default.jpg")) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + void testGetRegionSuccess() throws Exception { + var pid = makePid(); + var pidString = pid.getId(); + var formattedBasePath = "/iiif/v3/" + ImageServerUtil.getImageServerEncodedId(pidString); + var filename = "bunny.jpg"; + stubFor(WireMock.get(urlMatching(formattedBasePath + "/full/max/0/default.jpg")) + .willReturn(aResponse() + .withStatus(HttpStatus.OK.value()) + .withBody(filename) + .withHeader("Content-Type", "image/jpeg"))); + + MvcResult result = mvc.perform(get("/iiif/v3/" + pidString + "/full/max/0/default.jpg")) + .andExpect(status().is2xxSuccessful()) + .andReturn(); + + var response = result.getResponse(); + Assertions.assertEquals(response.getContentAsString(), filename); + } + + @Test + void testGetRegionIOException() throws Exception { + var pid = makePid(); + var pidString = pid.getId(); + doThrow(new IOException()).when(imageServerProxyService) + .streamJP2(pidString, "full", "max", "0", "default", "jpg"); + + mvc.perform(get("/iiif/v3/" + pidString + "/full/max/0/default.jpg")) + .andExpect(status().isInternalServerError()) + .andReturn(); + } + + @Test + void testGetMetadataNoAccess() throws Exception { + var pid = makePid(); + var pidString = pid.getId(); + doThrow(new AccessRestrictionException()).when(accessControlService) + .assertHasAccess(anyString(), eq(pid), any(AccessGroupSetImpl.class), eq(viewAccessCopies)); + + mvc.perform(get("/iiif/v3/" + pidString + "/info.json")) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + void testGetMetadataSuccess() throws Exception { + var pid = makePid(); + var pidString = pid.getId(); + var formattedBasePath = "/iiif/v3/" + ImageServerUtil.getImageServerEncodedId(pidString); + var json = "{\"@context\":\"http://iiif.io/api/image/3/context.json\",\"id\":\"http://example.com/iiif/v3/" + + pidString + "\",\"type\":\"ImageService3\",\"protocol\":\"http://iiif.io/api/image\"}"; + stubFor(WireMock.get(urlMatching(formattedBasePath + "/info.json")) + .willReturn(aResponse() + .withStatus(HttpStatus.OK.value()) + .withBody(json) + .withHeader("Content-Type", "application/json"))); + + MvcResult result = mvc.perform(get("/iiif/v3/" + pidString + "/info.json")) + .andExpect(status().is2xxSuccessful()) + .andReturn(); + + var response = result.getResponse(); + Assertions.assertEquals(response.getContentAsString(), json); + } + + @Test + void testGetMetadataIOException() throws Exception { + var pid = makePid(); + var pidString = pid.getId(); + var formattedBasePath = "/iiif/v3/" + ImageServerUtil.getImageServerEncodedId(pidString); + doThrow(new IOException()).when(imageServerProxyService).getMetadata(pidString); + + stubFor(WireMock.get(urlMatching(formattedBasePath + "/info.json")) + .willReturn(aResponse().withHeader("Content-Type", "application/json"))); + + mvc.perform(get("/iiif/v3/" + pidString + "/info.json")) + .andExpect(status().isInternalServerError()) + .andReturn(); + } +} diff --git a/web-services-app/src/test/resources/image-server-proxy-servlet.xml b/web-services-app/src/test/resources/image-server-proxy-servlet.xml new file mode 100644 index 0000000000..6de45392a8 --- /dev/null +++ b/web-services-app/src/test/resources/image-server-proxy-servlet.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file