From ac05c49ee007909998bff91603d263f4fb209713 Mon Sep 17 00:00:00 2001 From: Steven Choi Date: Tue, 26 Sep 2023 12:57:44 +1000 Subject: [PATCH] #785 Add APIs to access images --- .../org/ala/profile/api/ApiController.groovy | 174 ++++++++++++++++++ .../org/ala/profile/api/ApiInterceptor.groovy | 2 + .../au/org/ala/profile/hub/UrlMappings.groovy | 2 + .../au/org/ala/profile/api/ApiService.groovy | 34 ++++ .../ala/profile/api/ApiControllerSpec.groovy | 55 ++++++ 5 files changed, 267 insertions(+) diff --git a/grails-app/controllers/au/org/ala/profile/api/ApiController.groovy b/grails-app/controllers/au/org/ala/profile/api/ApiController.groovy index 7b9951d2..7d9a3449 100644 --- a/grails-app/controllers/au/org/ala/profile/api/ApiController.groovy +++ b/grails-app/controllers/au/org/ala/profile/api/ApiController.groovy @@ -2,8 +2,11 @@ package au.org.ala.profile.api import au.ala.org.ws.security.RequireApiKey import au.org.ala.profile.hub.BaseController +import au.org.ala.profile.hub.ImageType import au.org.ala.profile.hub.MapService import au.org.ala.profile.hub.ProfileService +import au.org.ala.profile.hub.Utils +import au.org.ala.profile.security.GrantAccess import au.org.ala.profile.security.RequiresAccessToken import grails.converters.JSON @@ -510,6 +513,177 @@ class ApiController extends BaseController { } } + @GrantAccess + @Path("/api/opus/{opusId}/profile/{profileId}/image/{imageId}") + @Operation( + summary = "Get images associated with a profile", + operationId = "/api/opus/{opusId}/profile/{profileId}/image/{imageId}", + method = "GET", + responses = [ + @ApiResponse( + responseCode = "200", + content = @Content( + mediaType = "application/json", + schema = @Schema( + implementation = ImageListResponse.class + ) + ), + headers = [ + @Header(name = 'X-Total-Count', description = "Total number of images", schema = @Schema(type = "integer")), + @Header(name = 'Access-Control-Allow-Headers', description = "CORS header", schema = @Schema(type = "String")), + @Header(name = 'Access-Control-Allow-Methods', description = "CORS header", schema = @Schema(type = "String")), + @Header(name = 'Access-Control-Allow-Origin', description = "CORS header", schema = @Schema(type = "String")) + ] + ), + @ApiResponse(responseCode = "400", + description = "opusId and profileId are required parameters"), + @ApiResponse(responseCode = "403", + description = "You do not have the necessary permissions to perform this action."), + @ApiResponse(responseCode = "405", + description = "An unexpected error has occurred while processing your request."), + @ApiResponse(responseCode = "404", + description = "Opus or profile not found"), + @ApiResponse(responseCode = "500", + description = "An unexpected error has occurred while processing your request.") + ], + parameters = [ + @Parameter(name = "opusId", + in = ParameterIn.PATH, + required = true, + description = "Collection id - UUID or short name"), + @Parameter(name = "profileId", + in = ParameterIn.PATH, + required = true, + description = "Profile id - UUID or Scientific name"), + @Parameter(name = "type", + in = ParameterIn.PATH, + required = true, + description = "type - private or open"), + @Parameter(name = "Access-Token", + in = ParameterIn.HEADER, + required = false, + description = "Access token to read private collection"), + @Parameter(name = "Accept-Version", + in = ParameterIn.HEADER, + required = true, + description = "The API version", + schema = @Schema( + name = "Accept-Version", + type = "string", + defaultValue = '1.0', + allowableValues = ["1.0"] + ) + ) + ], + security = [@SecurityRequirement(name="auth"), @SecurityRequirement(name = "oauth")] + ) + def getLocalImage () { + if (!params.type) { + badRequest "type is a required parameter" + } else { + try { + ImageType type = params.type as ImageType + if (type == ImageType.PRIVATE) { + def result = apiService.displayLocalImage("${grailsApplication.config.image.private.dir}/", params.opusId, params.profileId, params.imageId, false) + if (result) { + response.setHeader("Content-disposition", "attachment;filename=${params.imageId}") + response.setContentType(Utils.getContentType(result)) + result.withInputStream { response.outputStream << it } + } + } + } catch (IllegalArgumentException e) { + log.warn(e) + badRequest "Invalid image type ${params.type}" + } + } + } + + @GrantAccess + @Path("/api/opus/{opusId}/profile/{profileId}/image/{imageId}") + @Operation( + summary = "Get images associated with a profile", + operationId = "/api/opus/{opusId}/profile/{profileId}/image/{imageId}", + method = "GET", + responses = [ + @ApiResponse( + responseCode = "200", + content = @Content( + mediaType = "application/json", + schema = @Schema( + implementation = ImageListResponse.class + ) + ), + headers = [ + @Header(name = 'X-Total-Count', description = "Total number of images", schema = @Schema(type = "integer")), + @Header(name = 'Access-Control-Allow-Headers', description = "CORS header", schema = @Schema(type = "String")), + @Header(name = 'Access-Control-Allow-Methods', description = "CORS header", schema = @Schema(type = "String")), + @Header(name = 'Access-Control-Allow-Origin', description = "CORS header", schema = @Schema(type = "String")) + ] + ), + @ApiResponse(responseCode = "400", + description = "opusId and profileId are required parameters"), + @ApiResponse(responseCode = "403", + description = "You do not have the necessary permissions to perform this action."), + @ApiResponse(responseCode = "405", + description = "An unexpected error has occurred while processing your request."), + @ApiResponse(responseCode = "404", + description = "Opus or profile not found"), + @ApiResponse(responseCode = "500", + description = "An unexpected error has occurred while processing your request.") + ], + parameters = [ + @Parameter(name = "opusId", + in = ParameterIn.PATH, + required = true, + description = "Collection id - UUID or short name"), + @Parameter(name = "profileId", + in = ParameterIn.PATH, + required = true, + description = "Profile id - UUID or Scientific name"), + @Parameter(name = "type", + in = ParameterIn.PATH, + required = true, + description = "type - private or open"), + @Parameter(name = "Access-Token", + in = ParameterIn.HEADER, + required = false, + description = "Access token to read private collection"), + @Parameter(name = "Accept-Version", + in = ParameterIn.HEADER, + required = true, + description = "The API version", + schema = @Schema( + name = "Accept-Version", + type = "string", + defaultValue = '1.0', + allowableValues = ["1.0"] + ) + ) + ], + security = [@SecurityRequirement(name="auth"), @SecurityRequirement(name = "oauth")] + ) + + def retrieveLocalThumbnailImage () { + if (!params.type) { + badRequest "type is a required parameter" + } else { + try { + ImageType type = params.type as ImageType + if (type == ImageType.PRIVATE) { + def result = apiService.displayLocalImage("${grailsApplication.config.image.private.dir}/", params.opusId, params.profileId, params.imageId, true) + if (result) { + response.setHeader("Content-disposition", "attachment;filename=${params.imageId}") + response.setContentType(Utils.getContentType(result)) + result.withInputStream { response.outputStream << it } + } + } + } catch (IllegalArgumentException e) { + log.warn(e) + badRequest "Invalid image type ${params.type}" + } + } + } + @Path("/api/opus/{opusId}/profile/{profileId}/attribute/{attributeId}") @Operation( summary = "Get attributes of a profile in a collection", diff --git a/grails-app/controllers/au/org/ala/profile/api/ApiInterceptor.groovy b/grails-app/controllers/au/org/ala/profile/api/ApiInterceptor.groovy index 00220bff..adf01e8d 100644 --- a/grails-app/controllers/au/org/ala/profile/api/ApiInterceptor.groovy +++ b/grails-app/controllers/au/org/ala/profile/api/ApiInterceptor.groovy @@ -38,6 +38,8 @@ class ApiInterceptor { } else { authorised = true } + } else if (method?.isAnnotationPresent(GrantAccess)) { + authorised = true } } diff --git a/grails-app/controllers/au/org/ala/profile/hub/UrlMappings.groovy b/grails-app/controllers/au/org/ala/profile/hub/UrlMappings.groovy index 971755e6..ebc56f20 100644 --- a/grails-app/controllers/au/org/ala/profile/hub/UrlMappings.groovy +++ b/grails-app/controllers/au/org/ala/profile/hub/UrlMappings.groovy @@ -261,6 +261,8 @@ class UrlMappings { get "/opus/$opusId/profile" (version: "1.0", controller: "api", action: "getProfiles", namespace: "v1") get "/opus/$opusId/profile/$profileId" (version: "1.0", controller: "api", action: "get", namespace: "v1") get "/opus/$opusId/profile/$profileId/image" (version: "1.0", controller: "api", action: "getImages", namespace: "v1") + get "/opus/$opusId/profile/$profileId/image/$imageId" (version: "1.0", controller: "api", action: "getLocalImage", namespace: "v1") + get "/opus/$opusId/profile/$profileId/image/thumbnail/$imageId" (version: "1.0", controller: "api", action: "retrieveLocalThumbnailImage", namespace: "v1") get "/opus/$opusId/profile/$profileId/attribute/$attributeId" (version: "1.0", controller: "api", action: "getAttributes", namespace: "v1") get "/opus/$opusId/profile/$profileId/draft" (version: "1.0", controller: "api", action: "getDraftProfile", namespace: "v1") } diff --git a/grails-app/services/au/org/ala/profile/api/ApiService.groovy b/grails-app/services/au/org/ala/profile/api/ApiService.groovy index d702961b..8ffa81b9 100644 --- a/grails-app/services/au/org/ala/profile/api/ApiService.groovy +++ b/grails-app/services/au/org/ala/profile/api/ApiService.groovy @@ -3,6 +3,7 @@ package au.org.ala.profile.api import au.org.ala.profile.hub.ImageService import au.org.ala.profile.hub.NslService import au.org.ala.profile.hub.ProfileService +import au.org.ala.profile.hub.Utils import grails.web.mapping.LinkGenerator import static au.org.ala.profile.hub.Utils.encPath @@ -138,4 +139,37 @@ class ApiService { protected isSuccessful(int statusCode) { return statusCode >= SC_OK && statusCode <= 299 } + + def displayLocalImage(String path, String collectionId, String profileId, String fileName, Boolean thumbnail) { + if (!fileName) { + badRequest "fileId is a required parameter" + } else { + File file + String imageId = fileName.substring(0, fileName.lastIndexOf(".")) + if (thumbnail) { + String thumbnailName = makeThumbnailName(fileName) + file = new File("${path}/${collectionId}/${profileId}/${imageId}/${imageId}_thumbnails/${thumbnailName}") + if (!file.exists()) { //use the image if there is no thumbnail + file = new File("${path}/${collectionId}/${profileId}/${imageId}/${fileName}") + } + } else { + file = new File("${path}/${collectionId}/${profileId}/${imageId}/${fileName}") + } + + if (!file.exists()) { //support for version 1 file locations + file = new File("${path}/${profileId}/${fileName}") + } + if (!file.exists()) { + notFound "The requested file could not be found" + } else { + return file + } + } + } + + String makeThumbnailName(String fileName) { + String extension = Utils.getExtension(fileName) + String imageId = fileName.substring(0, fileName.lastIndexOf('.')) + "${imageId}_thumbnail${extension}" + } } diff --git a/src/test/groovy/au/org/ala/profile/api/ApiControllerSpec.groovy b/src/test/groovy/au/org/ala/profile/api/ApiControllerSpec.groovy index ee560ec6..d1c6c3d8 100644 --- a/src/test/groovy/au/org/ala/profile/api/ApiControllerSpec.groovy +++ b/src/test/groovy/au/org/ala/profile/api/ApiControllerSpec.groovy @@ -149,4 +149,59 @@ class ApiControllerSpec extends Specification implements ControllerUnitTest