diff --git a/doc/release-notes/11760-tool url apis.md b/doc/release-notes/11760-tool url apis.md new file mode 100644 index 00000000000..a54641a29e6 --- /dev/null +++ b/doc/release-notes/11760-tool url apis.md @@ -0,0 +1,10 @@ +New API calls have been added to retrieve the URLs needed to launch external tools on specific datasets and files: + +/api/datasets/$DATASET_ID/externalTool/$TOOL_ID/toolUrl +and +/api/files/$FILE_ID/externalTool/$TOOL_ID/toolUrl + +If the dataset/file is not public, the caller must authenticate and have permission to view the dataset/file. In such cases, the generated URL will include a callback token containing a signed URL the tool can use to retrieve all the parameters it is configured for. + +Backward incompatibility: +The responses from the GET /api/externalTools and /api/externalTools/{id} are now formatted as JSON (previously the toolParameters and allowedApiCalls were JSON serialized as strings) and any confiugured "requirements" are included. \ No newline at end of file diff --git a/doc/sphinx-guides/source/api/changelog.rst b/doc/sphinx-guides/source/api/changelog.rst index 16157459220..5be6c78adce 100644 --- a/doc/sphinx-guides/source/api/changelog.rst +++ b/doc/sphinx-guides/source/api/changelog.rst @@ -13,6 +13,7 @@ v6.8 - For POST /api/files/{id}/metadata passing an empty string ("description":"") or array ("categories":[]) will no longer be ignored. Empty fields will now clear out the values in the file's metadata. To ignore the fields simply do not include them in the JSON string. - For PUT /api/datasets/{id}/editMetadata the query parameter "sourceInternalVersionNumber" has been removed and replaced with "sourceLastUpdateTime" to verify that the data being edited hasn't been modified and isn't stale. - For GET /api/dataverses/$dataverse-alias/links the Json response has changed breaking the backward compatibility of the API. +- For GET /api/externalTools and /api/externalTools/{id} the responses are now formatted as JSON (previously the toolParameters and allowedApiCalls were a JSON object and array (respectively) that were serialized as JSON strings) and any configured "requirements" are included. v6.7 ---- diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 04aaf59de98..dc92e97b629 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -3442,10 +3442,62 @@ Archiving is an optional feature that may be configured for a Dataverse installa curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE "$SERVER_URL/api/datasets/:persistentId/$VERSION/archivalStatus?persistentId=$PERSISTENT_IDENTIFIER" +Get Dataset External Tool URL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This API call generates a URL for accessing an external tool (see :doc:`/installation/external-tools`) that operates at the dataset level. The URL includes necessary authentication tokens and parameters based on the user's permissions and the tool's configuration. + +Authentication is required for draft or deaccessioned datasets and the user must have ViewUnpublishedDataset permission. + +.. code-block:: bash + + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export PERSISTENT_IDENTIFIER=doi:10.5072/FK2/7U7YBV + export TOOL_ID=42 + + curl -H "X-Dataverse-key:$API_TOKEN" -X POST "$SERVER_URL/api/datasets/:persistentId/externalTool/$TOOL_ID/toolUrl?persistentId=$PERSISTENT_IDENTIFIER" \ + -H "Content-Type: application/json" \ + -d '{"preview": false, "locale": "en"}' + +The fully expanded example above (without environment variables) looks like this: + +.. code-block:: bash + + curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X POST "https://demo.dataverse.org/api/datasets/:persistentId/externalTool/42/toolUrl?persistentId=doi:10.5072/FK2/7U7YBV" \ + -H "Content-Type: application/json" \ + -d '{"preview": false, "locale": "en"}' + +The JSON request body accepts the following optional parameters: + +- ``preview``: boolean flag to indicate if the tool should run in preview mode (default: false) +- ``locale``: string specifying the locale for internationalization + +The response includes: + +- ``toolUrl``: the URL to access the external tool +- ``toolName``: the display name of the external tool +- ``datasetId``: the ID of the dataset +- ``preview``: whether the URL is for preview mode + +Example response: + +.. code-block:: json + + { + "status": "OK", + "data": { + "toolUrl": "https://example.com/tool?datasetId=1234&callback=ahr...", + "toolName": "My External Tool", + "datasetId": 1234, + "preview": false + } + } + Get External Tool Parameters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This API call is intended as a callback that can be used by :doc:`/installation/external-tools` to retrieve signed Urls necessary for their interaction with Dataverse. +This API call is intended as a callback that can be used by :doc:`/installation/external-tools` to retrieve signed URLs necessary for their interaction with Dataverse. It can be called directly as well. The response is a JSON object described in the :doc:`/api/external-tools` section of the API guide. @@ -5281,6 +5333,82 @@ Note the optional "limit" parameter. Without it, the API will attempt to populat By default, the admin API calls are blocked and can only be called from localhost. See more details in :ref:`:BlockedApiEndpoints <:BlockedApiEndpoints>` and :ref:`:BlockedApiPolicy <:BlockedApiPolicy>` settings in :doc:`/installation/config`. +Get File External Tool URL +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This API call generates a URL for accessing an external tool (see :doc:`/installation/external-tools`) that operates at the file level. The URL includes necessary authentication tokens and parameters based on the user's permissions and the tool's configuration. + +Authentication is required for draft, restricted, embargoed, or expired (retention period) files; the user must have appropriate permissions. + +.. code-block:: bash + + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export FILE_ID=42 + export TOOL_ID=3 + + curl -H "X-Dataverse-key:$API_TOKEN" -X POST "$SERVER_URL/api/files/$FILE_ID/externalTool/$TOOL_ID/toolUrl" \ + -H "Content-Type: application/json" \ + -d '{"preview": false, "locale": "en"}' + +The fully expanded example above (without environment variables) looks like this: + +.. code-block:: bash + + curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X POST "https://demo.dataverse.org/api/files/42/externalTool/3/toolUrl" \ + -H "Content-Type: application/json" \ + -d '{"preview": false, "locale": "en"}' + +The JSON request body accepts the following optional parameters: + +- ``preview``: boolean flag to indicate if the tool should run in preview mode (default: false) +- ``locale``: string specifying the locale for internationalization + +The response includes: + +- ``toolUrl``: the URL to access the external tool +- ``toolName``: the display name of the external tool +- ``fileId``: the ID of the file +- ``preview``: whether the URL is for preview mode + +Example response: + +.. code-block:: json + + { + "status": "OK", + "data": { + "toolUrl": "https://example.com/tool?fileId=42&callback=ahr...", + "toolName": "File Viewer Tool", + "fileId": 42, + "preview": false + } + } + +Get File External Tool Parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This API call is intended as a callback that can be used by :doc:`/installation/external-tools` to retrieve signed URLs necessary for their interaction with Dataverse files. +It can be called directly as well. + +The response is a JSON object described in the :doc:`/api/external-tools` section of the API guide. + +.. code-block:: bash + + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export FILE_ID=42 + export FILE_METADATA_ID=123 + export TOOL_ID=3 + + curl -H "X-Dataverse-key: $API_TOKEN" -H "Accept:application/json" "$SERVER_URL/api/files/$FILE_ID/metadata/$FILE_METADATA_ID/toolparams/$TOOL_ID" + +The fully expanded example above (without environment variables) looks like this: + +.. code-block:: bash + + curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -H "Accept:application/json" "https://demo.dataverse.org/api/files/42/metadata/123/toolparams/3" + Get External Tool Parameters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/main/java/edu/harvard/iq/dataverse/FilePage.java b/src/main/java/edu/harvard/iq/dataverse/FilePage.java index 72dde0d7d0e..50ebbc33634 100644 --- a/src/main/java/edu/harvard/iq/dataverse/FilePage.java +++ b/src/main/java/edu/harvard/iq/dataverse/FilePage.java @@ -1138,8 +1138,10 @@ public void setSelectedTool(ExternalTool selectedTool) { public String preview(ExternalTool externalTool) { ApiToken apiToken = null; User user = session.getUser(); - if (fileMetadata.getDatasetVersion().isDraft() || fileMetadata.getDatasetVersion().isDeaccessioned() || (fileMetadata.getDataFile().isRestricted()) || (FileUtil.isActivelyEmbargoed(fileMetadata))) { - apiToken=authService.getValidApiTokenForUser(user); + if (fileMetadata.getDatasetVersion().isDraft() || (fileMetadata.getDataFile().isRestricted()) + || fileMetadata.getDatasetVersion().isDeaccessioned() || (FileUtil.isActivelyEmbargoed(fileMetadata)) + || (FileUtil.isRetentionExpired(fileMetadata))) { + apiToken = authService.getValidApiTokenForUser(user); } if(externalTool == null){ return ""; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java index 729174dedfc..fb97c761ef1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -5153,6 +5153,129 @@ private boolean isSingleVersionArchiving() { return false; } + /** + * API endpoint to retrieve a URL for a dataset-level external tool. + * + * This endpoint allows clients to get a URL for accessing an external tool + * that operates at the dataset level. The URL includes necessary authentication tokens and + * parameters based on the user's permissions and the tool's configuration. + * + * The endpoint accepts JSON input with optional parameters: + * - preview: boolean flag to indicate if the tool should run in preview mode (preview mode, if supported by the tool, suppresses showing metadata (i.e. item name/PID) and is intended for cases where the tool is embedded in the dataset/file page and this metadata is not needed. The current JSF UI never embeds a dataset-level tool in an iframe, so this is param is not currently useful (and may not be supported in dataset tools yet) + * - locale: string specifying the locale for internationalization + * + * The response includes: + * - toolUrl: the URL to access the external tool + * - toolName: the display name of the external tool + * - datasetId: the ID of the dataset + * - preview: whether the URL is for preview mode + * + * Authentication is required, and appropriate permissions are checked before generating the URL. + * For restricted datasets (draft or deaccessioned), the user must have ViewUnpublishedDataset permission. + * + * @param crc The container request context for authentication + * @param datasetId The ID of the dataset + * @param externalToolId The ID of the external tool + * @param jsonBody JSON containing optional parameters + * @return A Response with the external tool URL and related information + */ +@POST +@AuthRequired +@Path("{id}/externalTool/{tid}/toolUrl") +@Consumes(MediaType.APPLICATION_JSON) +public Response getDatasetExternalToolUrl(@Context ContainerRequestContext crc, @PathParam("id") String datasetId, + @PathParam("tid") long externalToolId, String jsonBody) { + + boolean preview = false; + String locale = null; + + // Parse request body for parameters + if (StringUtils.isNotBlank(jsonBody)) { + try { + jakarta.json.JsonObject jsonObject = JsonUtil.getJsonObject(jsonBody); + if (jsonObject.containsKey("preview")) { + preview = jsonObject.getBoolean("preview"); + } + if (jsonObject.containsKey("locale")) { + locale = jsonObject.getString("locale"); + } + } catch (JsonParsingException | NullPointerException e) { + logger.warning("Error parsing JSON: " + e.getMessage()); + // Return an error response for malformed JSON + return error(Response.Status.BAD_REQUEST, "Invalid JSON format in request body"); + } + } + + try { + // Find the dataset + Dataset dataset; + try { + dataset = findDatasetOrDie(datasetId); + } catch (WrappedResponse ex) { + return notFound("Dataset not found for given id: " + datasetId); + } + + // Find the external tool + ExternalTool externalTool = externalToolService.findById(externalToolId); + if (externalTool == null) { + return error(BAD_REQUEST, "External tool not found with id: " + externalToolId); + } + + // Check if the tool has dataset scope + if (!ExternalTool.Scope.DATASET.equals(externalTool.getScope())) { + return error(BAD_REQUEST, "External tool does not have dataset scope."); + } + + // Get the current user and create a request object + User user = getRequestUser(crc); + DataverseRequest req = createDataverseRequest(user); + + // Get the latest dataset version + DatasetVersion datasetVersion = dataset.getLatestVersion(); + if (datasetVersion == null) { + return error(BAD_REQUEST, "Dataset version not found."); + } + + // Check if the dataset is restricted or draft + boolean isRestricted = datasetVersion.isDraft() || datasetVersion.isDeaccessioned(); + + // Check if user has permission to access the dataset if it's restricted + if (isRestricted) { + boolean hasPermission = permissionSvc.requestOn(req, dataset).has(Permission.ViewUnpublishedDataset); + if (!hasPermission) { + return error(Response.Status.FORBIDDEN, + "You do not have permission to access this dataset with the requested external tool."); + } + } + + // Determine if we need an API token for authentication + ApiToken apiToken = null; + if (user.isAuthenticated() && isRestricted) { + apiToken = authSvc.getValidApiTokenForUser(user); + } + + // Create the external tool handler + ExternalToolHandler externalToolHandler = new ExternalToolHandler(externalTool, dataset, apiToken, locale); + + // Get the tool URL + String toolUrl; + if (preview) { + toolUrl = externalToolHandler.getToolUrlForPreviewMode(); + } else { + toolUrl = externalToolHandler.getToolUrlWithQueryParams(); + } + + // Return the URL in a JSON response + return ok(Json.createObjectBuilder().add("toolUrl", toolUrl).add("displayName", externalTool.getDisplayName()) + .add("datasetId", dataset.getId()).add("preview", preview)); + + } catch (Exception ex) { + logger.log(Level.SEVERE, "Error getting dataset external tool URL: " + ex.getMessage(), ex); + return error(Response.Status.INTERNAL_SERVER_ERROR, + "An error occurred while generating the external tool URL."); + } +} + // This method provides a callback for an external tool to retrieve it's // parameters/api URLs. If the request is authenticated, e.g. by it being // signed, the api URLs will be signed. If a guest request is made, the URLs diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java b/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java index 92139d86caf..6185e91b889 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java @@ -20,6 +20,7 @@ public class ExternalToolsApi extends AbstractApiBean { @GET public Response getExternalTools() { + //ToDo - allow filtering by scope, tool type, file content type, etc? return externalTools.getExternalTools(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Files.java b/src/main/java/edu/harvard/iq/dataverse/api/Files.java index 5834e7e0008..4ea9087b325 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Files.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Files.java @@ -67,6 +67,7 @@ import jakarta.ws.rs.core.UriInfo; +import org.apache.commons.lang3.StringUtils; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; @@ -854,7 +855,148 @@ private void exportDatasetMetadata(SettingsServiceBean settingsServiceBean, Data logger.log(Level.WARNING, "Dataset publication finalization: exception while exporting:{0}", ex.getMessage()); } } + + /** + * API endpoint to retrieve a URL for a file-level external tool. + * + * This endpoint allows clients to get a URL for accessing an external tool + * that operates at the file level. The URL includes necessary authentication tokens and + * parameters based on the user's permissions and the tool's configuration. + * + * The endpoint accepts JSON input with optional parameters: + * - preview: boolean flag to indicate if the tool should run in preview mode (suppressing header metadata like name/PID that would already be on the file page) + * - locale: string specifying the locale for internationalization + * + * The response includes: + * - toolUrl: the URL to access the external tool + * - toolName: the display name of the external tool + * - fileId: the ID of the file + * - preview: whether the URL is for preview mode + * + * Authentication is required, and appropriate permissions are checked before generating the URL. + * For restricted files (including files in draft/deaccessioned datasets, embargoed files, or + * files with expired retention periods), the user must have DownloadFile permission. + * + * @param crc The container request context for authentication + * @param fileId The ID of the file + * @param externalToolId The ID of the external tool + * @param jsonBody JSON containing optional parameters + * @return A Response with the external tool URL and related information + */ + @POST + @AuthRequired + @Path("{id}/externalTool/{tid}/toolUrl") + @Consumes(MediaType.APPLICATION_JSON) + public Response getExternalToolUrl(@Context ContainerRequestContext crc, @PathParam("id") String fileId, + @PathParam("tid") long externalToolId, String jsonBody) { + + boolean preview = false; + String locale = null; + + // Parse request body for parameters + if (StringUtils.isNotBlank(jsonBody)) { + try { + jakarta.json.JsonObject jsonObject = JsonUtil.getJsonObject(jsonBody); + if (jsonObject.containsKey("preview")) { + preview = jsonObject.getBoolean("preview"); + } + if (jsonObject.containsKey("locale")) { + locale = jsonObject.getString("locale"); + } + } catch (JsonParsingException | NullPointerException e) { + logger.warning("Error parsing JSON: " + e.getMessage()); + // Return a proper error response for malformed JSON + return error(Response.Status.BAD_REQUEST, "Invalid JSON format in request body."); + } + } + + try { + // Find the file + DataFile dataFile; + try { + dataFile = findDataFileOrDie(fileId); + } catch (WrappedResponse ex) { + return notFound("File not found for given id: " + fileId); + } + + // Find the external tool + ExternalTool externalTool = externalToolService.findById(externalToolId); + if (externalTool == null) { + return error(BAD_REQUEST, "External tool not found with id: " + externalToolId); + } + // Check if the tool has file scope + if (!ExternalTool.Scope.FILE.equals(externalTool.getScope())) { + return error(BAD_REQUEST, "External tool does not have file scope."); + } + + // Check if the tool's content type matches the file's content type + String toolContentType = externalTool.getContentType(); + String fileContentType = dataFile.getContentType(); + if (toolContentType != null && !toolContentType.isEmpty() && + !toolContentType.equals(fileContentType)) { + return error(BAD_REQUEST, + "External tool content type (" + toolContentType + + ") does not match file content type (" + fileContentType + ")."); + } + + if (!externalToolService.meetsRequirements(externalTool, dataFile)) { + return error(BAD_REQUEST, "External tool requirements not met for this file."); + } + + // Get the current user and create a request object + User user = getRequestUser(crc); + DataverseRequest req = createDataverseRequest(user); + + // Get the latest file metadata + FileMetadata fileMetadata = dataFile.getLatestFileMetadata(); + if (fileMetadata == null) { + return error(BAD_REQUEST, "File metadata not found."); + } + + // Check if the file is restricted or embargoed + boolean isRestricted = dataFile.isRestricted() || fileMetadata.getDatasetVersion().isDraft() + || FileUtil.isActivelyEmbargoed(fileMetadata) || fileMetadata.getDatasetVersion().isDeaccessioned() + || FileUtil.isRetentionExpired(fileMetadata); + + // Check if user has permission to download the file if it's restricted + if (isRestricted) { + boolean hasPermission = permissionSvc.requestOn(req, dataFile).has(Permission.DownloadFile); + if (!hasPermission) { + return error(Response.Status.FORBIDDEN, + "You do not have permission to access this file with the requested external tool."); + } + } + + // Determine if we need an API token for authentication + ApiToken apiToken = null; + if (user.isAuthenticated() && isRestricted) { + apiToken = authSvc.getValidApiTokenForUser(user); + } + + // Create the external tool handler + ExternalToolHandler externalToolHandler = new ExternalToolHandler(externalTool, dataFile, apiToken, + fileMetadata, locale); + + // Get the tool URL + String toolUrl; + if (preview) { + toolUrl = externalToolHandler.getToolUrlForPreviewMode(); + } else { + toolUrl = externalToolHandler.getToolUrlWithQueryParams(); + } + + // Return the URL in a JSON response + return ok(Json.createObjectBuilder().add("toolUrl", toolUrl).add("displayName", externalTool.getDisplayName()) + .add("fileId", dataFile.getId()).add("preview", preview)); + + } catch (Exception ex) { + logger.log(Level.SEVERE, "Error getting external tool URL: " + ex.getMessage(), ex); + return error(Response.Status.INTERNAL_SERVER_ERROR, + "An error occurred while generating the external tool URL."); + } + } + // This method provides a callback for an external tool to retrieve it's // parameters/api URLs. If the request is authenticated, e.g. by it being // signed, the api URLs will be signed. If a guest request is made, the URLs diff --git a/src/main/java/edu/harvard/iq/dataverse/api/TestApi.java b/src/main/java/edu/harvard/iq/dataverse/api/TestApi.java deleted file mode 100644 index 46747b50c29..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/TestApi.java +++ /dev/null @@ -1,129 +0,0 @@ -package edu.harvard.iq.dataverse.api; - -import edu.harvard.iq.dataverse.DataFile; -import edu.harvard.iq.dataverse.Dataset; -import edu.harvard.iq.dataverse.authorization.users.ApiToken; -import edu.harvard.iq.dataverse.externaltools.ExternalTool; -import edu.harvard.iq.dataverse.externaltools.ExternalToolHandler; -import java.util.List; -import jakarta.json.Json; -import jakarta.json.JsonArrayBuilder; -import jakarta.json.JsonObjectBuilder; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.Response; -import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; - -@Path("admin/test") -public class TestApi extends AbstractApiBean { - - @GET - @Path("datasets/{id}/externalTools") - public Response getDatasetExternalToolsforFile(@PathParam("id") String idSupplied, @QueryParam("type") String typeSupplied) { - ExternalTool.Type type; - try { - type = ExternalTool.Type.fromString(typeSupplied); - } catch (IllegalArgumentException ex) { - return error(BAD_REQUEST, ex.getLocalizedMessage()); - } - Dataset dataset; - try { - dataset = findDatasetOrDie(idSupplied); - JsonArrayBuilder tools = Json.createArrayBuilder(); - List datasetTools = externalToolService.findDatasetToolsByType(type); - for (ExternalTool tool : datasetTools) { - ApiToken apiToken = externalToolService.getApiToken(getRequestApiKey()); - ExternalToolHandler externalToolHandler = new ExternalToolHandler(tool, dataset, apiToken, null); - JsonObjectBuilder toolToJson = externalToolService.getToolAsJsonWithQueryParameters(externalToolHandler); - tools.add(toolToJson); - } - return ok(tools); - } catch (WrappedResponse wr) { - return wr.getResponse(); - } - } - - @GET - @Path("datasets/{id}/externalTool/{toolId}") - public Response getExternalToolforDatasetById(@PathParam("id") String idSupplied, @PathParam("toolId") String toolId, @QueryParam("type") String typeSupplied) { - ExternalTool.Type type; - try { - type = ExternalTool.Type.fromString(typeSupplied); - } catch (IllegalArgumentException ex) { - return error(BAD_REQUEST, ex.getLocalizedMessage()); - } - Dataset dataset; - try { - dataset = findDatasetOrDie(idSupplied); - JsonArrayBuilder tools = Json.createArrayBuilder(); - List datasetTools = externalToolService.findDatasetToolsByType(type); - for (ExternalTool tool : datasetTools) { - ApiToken apiToken = externalToolService.getApiToken(getRequestApiKey()); - ExternalToolHandler externalToolHandler = new ExternalToolHandler(tool, dataset, apiToken, null); - JsonObjectBuilder toolToJson = externalToolService.getToolAsJsonWithQueryParameters(externalToolHandler); - if (tool.getId().toString().equals(toolId)) { - return ok(toolToJson); - } - } - } catch (WrappedResponse wr) { - return wr.getResponse(); - } - return error(BAD_REQUEST, "Could not find external tool with id of " + toolId); - } - - @Path("files/{id}/externalTools") - @GET - public Response getExternalToolsForFile(@PathParam("id") String idSupplied, @QueryParam("type") String typeSupplied) { - ExternalTool.Type type; - try { - type = ExternalTool.Type.fromString(typeSupplied); - } catch (IllegalArgumentException ex) { - return error(BAD_REQUEST, ex.getLocalizedMessage()); - } - try { - DataFile dataFile = findDataFileOrDie(idSupplied); - JsonArrayBuilder tools = Json.createArrayBuilder(); - List datasetTools = externalToolService.findFileToolsByTypeAndContentType(type, dataFile.getContentType()); - for (ExternalTool tool : datasetTools) { - ApiToken apiToken = externalToolService.getApiToken(getRequestApiKey()); - ExternalToolHandler externalToolHandler = new ExternalToolHandler(tool, dataFile, apiToken, dataFile.getFileMetadata(), null); - JsonObjectBuilder toolToJson = externalToolService.getToolAsJsonWithQueryParameters(externalToolHandler); - if (externalToolService.meetsRequirements(tool, dataFile)) { - tools.add(toolToJson); - } - } - return ok(tools); - } catch (WrappedResponse wr) { - return wr.getResponse(); - } - } - - @Path("files/{id}/externalTool/{toolId}") - @GET - public Response getExternalToolForFileById(@PathParam("id") String idSupplied, @QueryParam("type") String typeSupplied, @PathParam("toolId") String toolId) { - ExternalTool.Type type; - try { - type = ExternalTool.Type.fromString(typeSupplied); - } catch (IllegalArgumentException ex) { - return error(BAD_REQUEST, ex.getLocalizedMessage()); - } - try { - DataFile dataFile = findDataFileOrDie(idSupplied); - List datasetTools = externalToolService.findFileToolsByTypeAndContentType(type, dataFile.getContentType()); - for (ExternalTool tool : datasetTools) { - ApiToken apiToken = externalToolService.getApiToken(getRequestApiKey()); - ExternalToolHandler externalToolHandler = new ExternalToolHandler(tool, dataFile, apiToken, dataFile.getFileMetadata(), null); - JsonObjectBuilder toolToJson = externalToolService.getToolAsJsonWithQueryParameters(externalToolHandler); - if (externalToolService.meetsRequirements(tool, dataFile) && tool.getId().toString().equals(toolId)) { - return ok(toolToJson); - } - } - return error(BAD_REQUEST, "Could not find external tool with id of " + toolId); - } catch (WrappedResponse wr) { - return wr.getResponse(); - } - } - -} diff --git a/src/main/java/edu/harvard/iq/dataverse/externaltools/ExternalTool.java b/src/main/java/edu/harvard/iq/dataverse/externaltools/ExternalTool.java index c103f6981e1..5ec551ba7c1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/externaltools/ExternalTool.java +++ b/src/main/java/edu/harvard/iq/dataverse/externaltools/ExternalTool.java @@ -2,6 +2,7 @@ import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.StringUtil; +import edu.harvard.iq.dataverse.util.json.JsonUtil; import java.io.Serializable; import java.util.Arrays; @@ -309,13 +310,17 @@ public JsonObjectBuilder toJson() { jab.add(TYPES, types); jab.add(SCOPE, getScope().text); jab.add(TOOL_URL, getToolUrl()); - jab.add(TOOL_PARAMETERS, getToolParameters()); + jab.add(TOOL_PARAMETERS, JsonUtil.getJsonObject(getToolParameters())); if (getContentType() != null) { jab.add(CONTENT_TYPE, getContentType()); } if (getAllowedApiCalls()!= null) { - jab.add(ALLOWED_API_CALLS,getAllowedApiCalls()); + jab.add(ALLOWED_API_CALLS,JsonUtil.getJsonArray(getAllowedApiCalls())); } + if(getRequirements()!= null) { + jab.add(REQUIREMENTS, JsonUtil.getJsonObject(getRequirements())); + } + return jab; } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java index 1956e0eb8df..6637ea290c1 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -7,8 +7,11 @@ import java.io.File; import java.io.IOException; import java.io.StringReader; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Base64; + import jakarta.json.Json; import jakarta.json.JsonArray; import jakarta.json.JsonObjectBuilder; @@ -22,6 +25,8 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + import org.junit.jupiter.api.Disabled; public class ExternalToolsIT { @@ -33,12 +38,15 @@ public static void setUp() { @Test public void testGetExternalTools() { + //Tests that the api exists - response may or may not include tools depending on when the test runs Response getExternalTools = UtilIT.getExternalTools(); getExternalTools.prettyPrint(); + getExternalTools.then().assertThat() + .statusCode(OK.getStatusCode()); } @Test - public void testExternalToolsNonAdminEndpoint() { + public void testExternalToolsLifecycle() { Response createUser = UtilIT.createRandomUser(); createUser.prettyPrint(); createUser.then().assertThat() @@ -84,42 +92,61 @@ public void testExternalToolsNonAdminEndpoint() { } """; + //Add tool + Response addExternalTool = UtilIT.addExternalTool(JsonUtil.getJsonObject(toolManifest), apiToken); addExternalTool.prettyPrint(); addExternalTool.then().assertThat() .statusCode(OK.getStatusCode()) .body("data.displayName", CoreMatchers.equalTo("Dataset Configurator")); + // Get by id Long toolId = JsonPath.from(addExternalTool.getBody().asString()).getLong("data.id"); - Response getExternalToolsByDatasetId = UtilIT.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); - getExternalToolsByDatasetId.prettyPrint(); - getExternalToolsByDatasetId.then().assertThat() + Response getExternalToolById = UtilIT.getExternalTool(toolId); + getExternalToolById.prettyPrint(); + getExternalToolById.then().assertThat() + .statusCode(OK.getStatusCode()) .body("data.displayName", CoreMatchers.equalTo("Dataset Configurator")) .body("data.scope", CoreMatchers.equalTo("dataset")) .body("data.types[0]", CoreMatchers.equalTo("configure")) - .body("data.toolUrlWithQueryParams", CoreMatchers.equalTo("https://datasetconfigurator.com?datasetPid=" + datasetPid)) - .statusCode(OK.getStatusCode()); + .body("data.toolParameters.queryParameters", Matchers.hasSize(2)); + // Get all tools for a given dataset - should be at least this one Response getExternalTools = UtilIT.getExternalTools(apiToken); getExternalTools.prettyPrint(); getExternalTools.then().assertThat() - .statusCode(OK.getStatusCode()); - Response getExternalTool = UtilIT.getExternalTool(toolId, apiToken); - getExternalTool.prettyPrint(); - getExternalTool.then().assertThat() - .statusCode(OK.getStatusCode()); + .statusCode(OK.getStatusCode()) + .body("data", Matchers.hasSize(Matchers.greaterThanOrEqualTo(1))); - // non superuser can only view tools + // Get tool launch Url for a give dataset + Response getExternalToolUrl = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken, null); + getExternalToolUrl.then().assertThat().statusCode(OK.getStatusCode()) + .body("data.displayName", CoreMatchers.equalTo("Dataset Configurator")) + .body("data.datasetId", CoreMatchers.equalTo(datasetId)) + .body("data.toolUrl", CoreMatchers.equalTo("https://datasetconfigurator.com?datasetPid=" + datasetPid)); + + // non superuser can also view tools UtilIT.setSuperuserStatus(username, false); + getExternalTools = UtilIT.getExternalTools(apiToken); getExternalTools.then().assertThat() .statusCode(OK.getStatusCode()); - getExternalToolsByDatasetId = UtilIT.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); - getExternalToolsByDatasetId.prettyPrint(); - getExternalToolsByDatasetId.then().assertThat() + + //Create user who can't see draft dataset + Response createUser2 = UtilIT.createRandomUser(); + createUser2.prettyPrint(); + createUser2.then().assertThat() .statusCode(OK.getStatusCode()); + String username2 = UtilIT.getUsernameFromResponse(createUser2); + String apiToken2 = UtilIT.getApiTokenFromResponse(createUser2); + + // User who can't see draft can't get a launch url (which would need to be signed for a draft dataset) + getExternalToolUrl = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken2, null); + getExternalToolUrl.then().assertThat() + .statusCode(FORBIDDEN.getStatusCode()); - //Add by non-superuser will fail + // User who can't see draft can't get a launch url (which would need to be signed for a draft dataset) + //Add by non-superuser will also fail addExternalTool = UtilIT.addExternalTool(JsonUtil.getJsonObject(toolManifest), apiToken); addExternalTool.then().assertThat() .statusCode(FORBIDDEN.getStatusCode()) @@ -137,6 +164,7 @@ public void testExternalToolsNonAdminEndpoint() { deleteExternalTool.prettyPrint(); deleteExternalTool.then().assertThat() .statusCode(OK.getStatusCode()); + } @Test @@ -211,30 +239,21 @@ public void testFileLevelTool1() { .body("data.scope", CoreMatchers.equalTo("file")) .statusCode(OK.getStatusCode()); - Response getExternalToolsForFileInvalidType = UtilIT.getExternalToolsForFile(tabularFileId.toString(), "invalidType", apiToken); - getExternalToolsForFileInvalidType.prettyPrint(); - getExternalToolsForFileInvalidType.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", CoreMatchers.equalTo("Type must be one of these values: [explore, configure, preview, query].")); - // Getting tool by tool Id to avoid issue where there are existing tools String toolIdString = toolId.toString(); - Response getExternalToolsForTabularFiles = UtilIT.getExternalToolForFileById(tabularFileId.toString(), "explore", apiToken, toolIdString); + Response getExternalToolsForTabularFiles = UtilIT.getFileToolUrl(tabularFileId.toString(), toolIdString, apiToken, null); getExternalToolsForTabularFiles.prettyPrint(); - getExternalToolsForTabularFiles.then().assertThat() .statusCode(OK.getStatusCode()) .body("data.displayName", CoreMatchers.equalTo("AwesomeTool")) - .body("data.scope", CoreMatchers.equalTo("file")) - .body("data.contentType", CoreMatchers.equalTo("text/tab-separated-values")) - .body("data.toolUrlWithQueryParams", CoreMatchers.equalTo("http://awesometool.com?fileid=" + tabularFileId + "&key=" + apiToken)); + .body("data.toolUrl", CoreMatchers.equalTo("http://awesometool.com?fileid=" + tabularFileId + "&key=" + apiToken)); - Response getExternalToolsForJuptyerNotebooks = UtilIT.getExternalToolsForFile(jupyterNotebookFileId.toString(), "explore", apiToken); + Response getExternalToolsForJuptyerNotebooks = UtilIT.getFileToolUrl(jupyterNotebookFileId.toString(), toolId.toString(), apiToken, null); getExternalToolsForJuptyerNotebooks.prettyPrint(); getExternalToolsForJuptyerNotebooks.then().assertThat() - .statusCode(OK.getStatusCode()) + .statusCode(BAD_REQUEST.getStatusCode()) // No tools for this file type. - .body("data", Matchers.hasSize(0)); + .body("message", CoreMatchers.equalTo("External tool content type (text/tab-separated-values) does not match file content type (application/x-ipynb+json).")); //Delete the tool added by this test... Response deleteExternalTool = UtilIT.deleteExternalTool(toolId); @@ -301,18 +320,11 @@ public void testDatasetLevelTool1() { Long toolId = JsonPath.from(addExternalTool.getBody().asString()).getLong("data.id"); - Response getExternalToolsByDatasetIdInvalidType = UtilIT.getExternalToolsForDataset(datasetId.toString(), "invalidType", apiToken); - getExternalToolsByDatasetIdInvalidType.prettyPrint(); - getExternalToolsByDatasetIdInvalidType.then().assertThat() - .statusCode(BAD_REQUEST.getStatusCode()) - .body("message", CoreMatchers.equalTo("Type must be one of these values: [explore, configure, preview, query].")); - - Response getExternalToolsByDatasetId = UtilIT.getExternalToolForDatasetById(datasetId.toString(), "explore", apiToken, toolId.toString()); + Response getExternalToolsByDatasetId = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken, null); getExternalToolsByDatasetId.prettyPrint(); getExternalToolsByDatasetId.then().assertThat() .body("data.displayName", CoreMatchers.equalTo("DatasetTool1")) - .body("data.scope", CoreMatchers.equalTo("dataset")) - .body("data.toolUrlWithQueryParams", CoreMatchers.equalTo("http://datasettool1.com?datasetPid=" + datasetPid + "&key=" + apiToken)) + .body("data.toolUrl", CoreMatchers.equalTo("http://datasettool1.com?datasetPid=" + datasetPid + "&key=" + apiToken)) .statusCode(OK.getStatusCode()); //Delete the tool added by this test... @@ -374,13 +386,11 @@ public void testDatasetLevelToolConfigure() { .body("data.displayName", CoreMatchers.equalTo("Dataset Configurator")); Long toolId = JsonPath.from(addExternalTool.getBody().asString()).getLong("data.id"); - Response getExternalToolsByDatasetId = UtilIT.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); + Response getExternalToolsByDatasetId = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken, null); getExternalToolsByDatasetId.prettyPrint(); getExternalToolsByDatasetId.then().assertThat() .body("data.displayName", CoreMatchers.equalTo("Dataset Configurator")) - .body("data.scope", CoreMatchers.equalTo("dataset")) - .body("data.types[0]", CoreMatchers.equalTo("configure")) - .body("data.toolUrlWithQueryParams", CoreMatchers.equalTo("https://datasetconfigurator.com?datasetPid=" + datasetPid)) + .body("data.toolUrl", CoreMatchers.equalTo("https://datasetconfigurator.com?datasetPid=" + datasetPid)) .statusCode(OK.getStatusCode()); //Delete the tool added by this test... @@ -704,20 +714,18 @@ public void testFileLevelToolWithAuxFileReq() throws IOException { .statusCode(OK.getStatusCode()); // No tools for false HDF5 file. Aux file couldn't be extracted. Doesn't meet requirements. - Response getToolsForFalseHdf5 = UtilIT.getExternalToolsForFile(falseHdf5.toString(), "preview", apiToken); + Response getToolsForFalseHdf5 = UtilIT.getFileToolUrl(falseHdf5.toString(), toolId.toString(), apiToken, null); getToolsForFalseHdf5.prettyPrint(); getToolsForFalseHdf5.then().assertThat() - .statusCode(OK.getStatusCode()) - .body("data", Matchers.hasSize(0)); + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", CoreMatchers.equalTo("External tool requirements not met for this file.")); // The tool shows for a true HDF5 file. The NcML aux file is available. Requirements met. - Response getToolsForTrueHdf5 = UtilIT.getExternalToolForFileById(trueHdf5.toString(), "preview", apiToken, toolId.toString()); + Response getToolsForTrueHdf5 = UtilIT.getFileToolUrl(trueHdf5.toString(), toolId.toString(), apiToken, null); getToolsForTrueHdf5.prettyPrint(); getToolsForTrueHdf5.then().assertThat() .statusCode(OK.getStatusCode()) - .body("data.displayName", CoreMatchers.equalTo("HDF5 Tool")) - .body("data.scope", CoreMatchers.equalTo("file")) - .body("data.contentType", CoreMatchers.equalTo("application/x-hdf5")); + .body("data.displayName", CoreMatchers.equalTo("HDF5 Tool")); //Delete the tool added by this test... Response deleteExternalTool = UtilIT.deleteExternalTool(toolId); @@ -725,5 +733,232 @@ public void testFileLevelToolWithAuxFileReq() throws IOException { .statusCode(OK.getStatusCode()); } + + +@Test +public void testExternalToolUrlApi() { + // Create a user + Response createUser = UtilIT.createRandomUser(); + createUser.prettyPrint(); + createUser.then().assertThat() + .statusCode(OK.getStatusCode()); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + + // Create a dataverse + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.prettyPrint(); + createDataverseResponse.then().assertThat() + .statusCode(CREATED.getStatusCode()); + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + // Create a dataset + Response createDataset = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, apiToken); + createDataset.prettyPrint(); + createDataset.then().assertThat() + .statusCode(CREATED.getStatusCode()); + Integer datasetId = UtilIT.getDatasetIdFromResponse(createDataset); + String datasetPid = JsonPath.from(createDataset.getBody().asString()).getString("data.persistentId"); + + // Upload a text file + String pathToTextFile = "src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java"; + Response uploadTextFile = UtilIT.uploadFileViaNative(datasetId.toString(), pathToTextFile, null, "text/plain", apiToken); + uploadTextFile.prettyPrint(); + uploadTextFile.then().assertThat() + .statusCode(OK.getStatusCode()); + Integer textFileId = JsonPath.from(uploadTextFile.getBody().asString()).getInt("data.files[0].dataFile.id"); + + // Create a dataset-level tool + JsonObjectBuilder datasetToolJob = Json.createObjectBuilder(); + datasetToolJob.add("displayName", "Dataset API Tool"); + datasetToolJob.add("description", "Tests the dataset-level tool URL API"); + datasetToolJob.add("types", Json.createArrayBuilder().add("explore")); + datasetToolJob.add("scope", "dataset"); + datasetToolJob.add("toolUrl", "http://example.org/dataset-tool"); + datasetToolJob.add("toolParameters", Json.createObjectBuilder() + .add("queryParameters", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("datasetId", "{datasetId}") + .build()) + .add(Json.createObjectBuilder() + .add("key", "{apiToken}") + .build()) + .build()) + .build()); + datasetToolJob.add("allowedApiCalls", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("name", "retrieveDatasetJson") + .add("httpMethod", "GET") + .add("urlTemplate", "/api/v1/datasets/{datasetId}") + .add("timeOut", 10) + .build()) + .build()); + + Response addDatasetTool = UtilIT.addExternalTool(datasetToolJob.build()); + addDatasetTool.prettyPrint(); + addDatasetTool.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.displayName", CoreMatchers.equalTo("Dataset API Tool")); + Long datasetToolId = JsonPath.from(addDatasetTool.getBody().asString()).getLong("data.id"); + + // Create a file-level tool for text/plain + JsonObjectBuilder fileToolJob = Json.createObjectBuilder(); + fileToolJob.add("displayName", "Text File Tool"); + fileToolJob.add("description", "Tests the file-level tool URL API"); + fileToolJob.add("types", Json.createArrayBuilder().add("explore")); + fileToolJob.add("scope", "file"); + fileToolJob.add("contentType", "text/plain"); + fileToolJob.add("toolUrl", "http://example.org/text-tool"); + fileToolJob.add("toolParameters", Json.createObjectBuilder() + .add("queryParameters", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("fileId", "{fileId}") + .build()) + .add(Json.createObjectBuilder() + .add("key", "{apiToken}") + .build()) + .build()) + .build()); + fileToolJob.add("allowedApiCalls", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("name", "retrieveFileContents") + .add("httpMethod", "GET") + .add("urlTemplate", "/api/v1/access/datafile/{fileId}?gbrecs=true") + .add("timeOut", 3600) + .build()) + .build()); + + Response addFileTool = UtilIT.addExternalTool(fileToolJob.build()); + addFileTool.prettyPrint(); + addFileTool.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.displayName", CoreMatchers.equalTo("Text File Tool")); + Long fileToolId = JsonPath.from(addFileTool.getBody().asString()).getLong("data.id"); + + // Test the dataset tool URL API + Response datasetToolUrl = UtilIT.getDatasetToolUrl(datasetId.toString(), datasetToolId.toString(), apiToken, null); + datasetToolUrl.prettyPrint(); + datasetToolUrl.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("status", CoreMatchers.equalTo("OK")) + .body("data.toolUrl", CoreMatchers.startsWith("http://example.org/dataset-tool")); + + // Extract the callback parameter + String toolUrl = JsonPath.from(datasetToolUrl.getBody().asString()).getString("data.toolUrl"); + String callbackParam = toolUrl.substring(toolUrl.indexOf("callback=") + 9); + if (callbackParam.contains("&")) { + callbackParam = callbackParam.substring(0, callbackParam.indexOf("&")); + } + + // Decode the callback URL + byte[] decodedBytes = Base64.getDecoder().decode(callbackParam); + String decodedCallback = new String(decodedBytes, StandardCharsets.UTF_8); + System.out.println("Decoded callback URL: " + decodedCallback); + + // Verify the callback URL contains the dataset API endpoint + assertTrue(decodedCallback.contains("toolparams/" + datasetToolId), + "Callback URL should contain a call to a/api/datasets/{id}/versions/{versionId}/toolparams/{toolId}"); + + // Actually call the callback URL and verify the response + Response callbackResponse = UtilIT.callCallbackUrl(decodedCallback); + callbackResponse.prettyPrint(); + callbackResponse.then().assertThat().statusCode(OK.getStatusCode()).body("status", CoreMatchers.equalTo("OK")); + + // Verify the response contains the dataset API endpoint + String callbackResponseBody = callbackResponse.getBody().asString(); + assertTrue(callbackResponseBody.contains("/api/v1/datasets/" + datasetId), + "Callback response should contain the dataset API endpoint"); + + // Verify the response contains the allowed API calls + assertTrue(callbackResponseBody.contains("retrieveDatasetJson"), + "Callback response should contain the allowed API call name"); + + // Test the file tool URL API + Response fileToolUrl = UtilIT.getFileToolUrl(textFileId.toString(), fileToolId.toString(), apiToken, null); + fileToolUrl.prettyPrint(); + fileToolUrl.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("status", CoreMatchers.equalTo("OK")) + .body("data.toolUrl", CoreMatchers.startsWith("http://example.org/text-tool")); + + // Extract the callback parameter from file tool URL + String fileToolUrlString = JsonPath.from(fileToolUrl.getBody().asString()).getString("data.toolUrl"); + String fileCallbackParam = fileToolUrlString.substring(fileToolUrlString.indexOf("callback=") + 9); + if (fileCallbackParam.contains("&")) { + fileCallbackParam = fileCallbackParam.substring(0, fileCallbackParam.indexOf("&")); + } + + // Decode the file tool callback URL + byte[] fileDecodedBytes = Base64.getDecoder().decode(fileCallbackParam); + String fileDecodedCallback = new String(fileDecodedBytes, StandardCharsets.UTF_8); + System.out.println("Decoded file callback URL: " + fileDecodedCallback); + + // Verify the file callback URL contains the file API endpoint + assertTrue(fileDecodedCallback.contains("toolparams/" + fileToolId), + "File callback URL should contain a call to api/files/{id}/toolparams/{toolId}"); + + // Actually call the file callback URL and verify the response + Response fileCallbackResponse = UtilIT.callCallbackUrl(fileDecodedCallback); + fileCallbackResponse.prettyPrint(); + fileCallbackResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("status", CoreMatchers.equalTo("OK")); + + // Verify the response contains the file API endpoint + String fileCallbackResponseBody = fileCallbackResponse.getBody().asString(); + assertTrue(fileCallbackResponseBody.contains("/api/v1/access/datafile/" + textFileId), + "File callback response should contain the file API endpoint"); + + // Verify the response contains the allowed API calls + assertTrue(fileCallbackResponseBody.contains("retrieveFileContents"), + "File callback response should contain the allowed API call name"); + assertTrue(fileCallbackResponseBody.contains("gbrecs=true"), + "File callback response should contain the query parameter"); + + // Test with preview mode + JsonObjectBuilder previewParams = Json.createObjectBuilder() + .add("preview", true) + .add("locale", "fr"); + + Response fileToolUrlWithPreview = UtilIT.getFileToolUrl( + textFileId.toString(), fileToolId.toString(), apiToken, previewParams.build()); + fileToolUrlWithPreview.prettyPrint(); + fileToolUrlWithPreview.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("status", CoreMatchers.equalTo("OK")) + .body("data.preview", CoreMatchers.equalTo(true)); + + // Clean up - delete the tools + Response deleteDatasetTool = UtilIT.deleteExternalTool(datasetToolId); + deleteDatasetTool.then().assertThat() + .statusCode(OK.getStatusCode()); + + Response deleteFileTool = UtilIT.deleteExternalTool(fileToolId); + deleteFileTool.then().assertThat() + .statusCode(OK.getStatusCode()); + + // Clean up - delete dataset, dataverse, and user + try { + // Delete dataset + Response deleteDataset = UtilIT.deleteDatasetViaNativeApi(datasetId, apiToken); + deleteDataset.then().assertThat() + .statusCode(OK.getStatusCode()); + + // Delete dataverse + Response deleteDataverse = UtilIT.deleteDataverse(dataverseAlias, apiToken); + deleteDataverse.then().assertThat() + .statusCode(OK.getStatusCode()); + + // Delete user + Response deleteUser = UtilIT.deleteUser(username); + deleteUser.then().assertThat() + .statusCode(OK.getStatusCode()); + } catch (Exception e) { + System.out.println("Error during cleanup: " + e.getMessage()); + e.printStackTrace(); + fail("Cleanup failed: " + e.getMessage()); + } + +} } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index 2f7b3d94990..ec692085348 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -2795,65 +2795,6 @@ static Response deleteExternalTool(long externalToolid, String apiToken) { return requestSpecification.delete("/api/externalTools/" + externalToolid); } - static Response getExternalToolsForDataset(String idOrPersistentIdOfDataset, String type, String apiToken) { - String idInPath = idOrPersistentIdOfDataset; // Assume it's a number. - String optionalQueryParam = ""; // If idOrPersistentId is a number we'll just put it in the path. - if (!NumberUtils.isCreatable(idOrPersistentIdOfDataset)) { - idInPath = ":persistentId"; - optionalQueryParam = "&persistentId=" + idOrPersistentIdOfDataset; - } - RequestSpecification requestSpecification = given(); - if (apiToken != null) { - requestSpecification = given() - .header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); - } - return requestSpecification.get("/api/admin/test/datasets/" + idInPath + "/externalTools?type=" + type + optionalQueryParam); - } - - static Response getExternalToolForDatasetById(String idOrPersistentIdOfDataset, String type, String apiToken, String toolId) { - String idInPath = idOrPersistentIdOfDataset; // Assume it's a number. - String optionalQueryParam = ""; // If idOrPersistentId is a number we'll just put it in the path. - if (!NumberUtils.isCreatable(idOrPersistentIdOfDataset)) { - idInPath = ":persistentId"; - optionalQueryParam = "&persistentId=" + idOrPersistentIdOfDataset; - } - RequestSpecification requestSpecification = given(); - if (apiToken != null) { - requestSpecification = given() - .header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); - } - return requestSpecification.get("/api/admin/test/datasets/" + idInPath + "/externalTool/" + toolId + "?type=" + type + optionalQueryParam); - } - - static Response getExternalToolsForFile(String idOrPersistentIdOfFile, String type, String apiToken) { - String idInPath = idOrPersistentIdOfFile; // Assume it's a number. - String optionalQueryParam = ""; // If idOrPersistentId is a number we'll just put it in the path. - if (!NumberUtils.isCreatable(idOrPersistentIdOfFile)) { - idInPath = ":persistentId"; - optionalQueryParam = "&persistentId=" + idOrPersistentIdOfFile; - } - RequestSpecification requestSpecification = given(); - if (apiToken != null) { - requestSpecification = given() - .header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); - } - return requestSpecification.get("/api/admin/test/files/" + idInPath + "/externalTools?type=" + type + optionalQueryParam); - } - - static Response getExternalToolForFileById(String idOrPersistentIdOfFile, String type, String apiToken, String toolId) { - String idInPath = idOrPersistentIdOfFile; // Assume it's a number. - String optionalQueryParam = ""; // If idOrPersistentId is a number we'll just put it in the path. - if (!NumberUtils.isCreatable(idOrPersistentIdOfFile)) { - idInPath = ":persistentId"; - optionalQueryParam = "&persistentId=" + idOrPersistentIdOfFile; - } - RequestSpecification requestSpecification = given(); - if (apiToken != null) { - requestSpecification = given() - .header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); - } - return requestSpecification.get("/api/admin/test/files/" + idInPath + "/externalTool/" + toolId + "?type=" + type + optionalQueryParam); - } static Response submitFeedback(JsonObjectBuilder job) { return given() @@ -4972,4 +4913,62 @@ public static Response getTemplates(String dataverseAlias, String apiToken) { .header(API_TOKEN_HTTP_HEADER, apiToken) .get("/api/dataverses/" + dataverseAlias + "/templates"); } + + /** + * Gets the tool URL for a dataset with optional parameters + * @param datasetId The ID of the dataset + * @param toolId The ID of the external tool + * @param apiToken The API token for authentication + * @param params Optional parameters (can be null). preview: boolean flag to + * indicate if the tool should run in preview mode (default: false), locale: + * string specifying the locale for internationalization + * @return Response from the API + */ + public static Response getDatasetToolUrl(String datasetId, String toolId, String apiToken, JsonObject params) { + RequestSpecification request = given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .contentType(ContentType.JSON); + + if (params != null) { + request = request.body(params.toString()); + } + + return request.post("/api/datasets/" + datasetId + "/externalTool/" + toolId + "/toolUrl"); + } + + /** + * Gets the tool URL for a file with optional parameters + * @param fileId The ID of the file + * @param toolId The ID of the external tool + * @param apiToken The API token for authentication + * @param params Optional parameters (can be null). preview: boolean flag to + * indicate if the tool should run in preview mode (default: false), locale: + * string specifying the locale for internationalization + * @return Response from the API + */ + public static Response getFileToolUrl(String fileId, String toolId, String apiToken, JsonObject params) { + RequestSpecification request = given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .contentType(ContentType.JSON); + + if (params != null) { + request = request.body(params.toString()); + } + + return request.post("/api/files/" + fileId + "/externalTool/" + toolId + "/toolUrl"); + } + + /** + * This simple does a get of the URL sent. It's initial use is to try the + * (potentially signed) callback URL sent to external tools. + * + * + * @param callbackUrl + * @return the response from the API + */ + public static Response callCallbackUrl(String callbackUrl) { + return given() + .when() + .get(callbackUrl); + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/externaltools/ExternalToolTest.java b/src/test/java/edu/harvard/iq/dataverse/externaltools/ExternalToolTest.java index ea8613b70bf..d7a6fa3ca5f 100644 --- a/src/test/java/edu/harvard/iq/dataverse/externaltools/ExternalToolTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/externaltools/ExternalToolTest.java @@ -34,7 +34,7 @@ public void testToJson() { assertEquals("myDescription", jsonObject.getString(ExternalTool.DESCRIPTION), "testToJson() with ExternalTool.DESCRIPTION"); assertEquals("explore", jsonObject.getJsonArray(ExternalTool.TYPES).getString(0), "testToJson() with ExternalTool.TYPES"); assertEquals("http://example.com", jsonObject.getString(ExternalTool.TOOL_URL), "testToJson() with ExternalTool.TOOL_URL"); - assertEquals("{}", jsonObject.getString(ExternalTool.TOOL_PARAMETERS), "testToJson() with ExternalTool.TOOL_PARAMETERS"); + assertEquals(0, jsonObject.getJsonObject(ExternalTool.TOOL_PARAMETERS).size(), "testToJson() with ExternalTool.TOOL_PARAMETERS should be empty"); assertEquals(DataFileServiceBean.MIME_TYPE_TSV_ALT, jsonObject.getString(ExternalTool.CONTENT_TYPE), "testToJson() with ExternalTool.CONTENT_TYPE"); }