From adb81c23f075c4c93bf411343e56dc0336700de7 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 21 Aug 2025 17:06:05 -0400 Subject: [PATCH 01/28] dataset api call --- .../harvard/iq/dataverse/api/Datasets.java | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) 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..353a3f021a5 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,132 @@ 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 + 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) { + // Return a proper error response for malformed JSON + return error(Response.Status.BAD_REQUEST, + "Invalid JSON format in request body: " + e.getMessage()); + } + + 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("status", ApiConstants.STATUS_OK) + .add("data", + Json.createObjectBuilder().add("toolUrl", toolUrl) + .add("toolName", externalTool.getDisplayName()) + .add("datasetId", dataset.getId()) + .add("preview", preview)) + .build()); + + } 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: " + ex.getMessage()); + } +} + // 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 From 0e97a19f51e4e8fa853a2d3831e091c93ee896ac Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 21 Aug 2025 17:06:21 -0400 Subject: [PATCH 02/28] file-level call --- .../edu/harvard/iq/dataverse/api/Files.java | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) 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..dd423e09e16 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Files.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Files.java @@ -854,7 +854,137 @@ 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 + + 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) { + // Return a proper error response for malformed JSON + return error(Response.Status.BAD_REQUEST, + "Invalid JSON format in request body: " + e.getMessage()); + } + + 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."); + } + + // 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("status", ApiConstants.STATUS_OK) + .add("data", + Json.createObjectBuilder().add("toolUrl", toolUrl) + .add("toolName", externalTool.getDisplayName()).add("fileId", dataFile.getId()) + .add("preview", preview)) + .build()); + + } 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: " + ex.getMessage()); + } + } + // 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 From 9d9d45de435d897479d53ad988be444dbc32be12 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 21 Aug 2025 17:06:41 -0400 Subject: [PATCH 03/28] add retention period check to match apis --- src/main/java/edu/harvard/iq/dataverse/FilePage.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 ""; From 73243fe6c3423be9d1cfc8cab26a6b82915fd054 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 21 Aug 2025 17:06:50 -0400 Subject: [PATCH 04/28] docs --- doc/sphinx-guides/source/api/native-api.rst | 125 +++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 04aaf59de98..13c422169db 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -3442,10 +3442,60 @@ 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 DATASET_ID=1234 + export TOOL_ID=42 + + curl -H "X-Dataverse-key:$API_TOKEN" -X POST "$SERVER_URL/api/datasets/$DATASET_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/datasets/1234/externalTool/42/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 +- ``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 +5331,79 @@ 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From fc6e66ec98cddb23a2b37f397aa9370a52112b02 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 21 Aug 2025 17:07:08 -0400 Subject: [PATCH 05/28] IT tests of dataset and file apis --- .../iq/dataverse/api/ExternalToolsIT.java | 234 ++++++++++++++++++ .../edu/harvard/iq/dataverse/api/UtilIT.java | 54 ++++ 2 files changed, 288 insertions(+) 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..9a6626fe7a3 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 { @@ -725,5 +730,234 @@ 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/util/testing-readme.txt"; + Response uploadTextFile = UtilIT.uploadFileViaNative(datasetId.toString(), pathToTextFile, 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?datasetId=" + datasetId)) + .body("data.toolName", CoreMatchers.equalTo("Dataset API 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?fileId=" + textFileId)) + .body("data.toolName", CoreMatchers.equalTo("Text File 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..fe4f71669b1 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -4972,4 +4972,58 @@ 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) + * @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) + * @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); + } } From 1a52520300cbf2488b069979874d74e5ec0fa73c Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 21 Aug 2025 17:16:08 -0400 Subject: [PATCH 06/28] release note --- doc/release-notes/11760-tool url apis.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 doc/release-notes/11760-tool url apis.md 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..1d654596a0d --- /dev/null +++ b/doc/release-notes/11760-tool url apis.md @@ -0,0 +1,7 @@ +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. \ No newline at end of file From 484e47e8108dcf0b03267e168a6a7a32f2f2c9ce Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 21 Aug 2025 17:21:01 -0400 Subject: [PATCH 07/28] fix title underline --- doc/sphinx-guides/source/api/native-api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 13c422169db..5336b784cd6 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -5382,7 +5382,7 @@ Example response: } 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. From a9b9f35c9a6135343995c55a98dbe502581b046e Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 21 Aug 2025 17:23:45 -0400 Subject: [PATCH 08/28] missing blank line --- doc/sphinx-guides/source/api/native-api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 5336b784cd6..7c2b892e8b3 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -5404,6 +5404,7 @@ 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From d3474a412278aa1fb1d80c6e3ed15b162f54fd47 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Fri, 22 Aug 2025 06:51:51 -0400 Subject: [PATCH 09/28] cleanup error responses per ai review --- .../edu/harvard/iq/dataverse/api/Datasets.java | 7 ++++--- .../java/edu/harvard/iq/dataverse/api/Files.java | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) 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 353a3f021a5..1a05772e2e2 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -5199,9 +5199,10 @@ public Response getDatasetExternalToolUrl(@Context ContainerRequestContext crc, locale = jsonObject.getString("locale"); } } catch (JsonParsingException | NullPointerException e) { - // Return a proper error response for malformed JSON + 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: " + e.getMessage()); + "Invalid JSON format in request body"); } try { @@ -5275,7 +5276,7 @@ public Response getDatasetExternalToolUrl(@Context ContainerRequestContext crc, } 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: " + ex.getMessage()); + "An error occurred while generating the external tool URL."); } } 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 dd423e09e16..059d7587c66 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Files.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Files.java @@ -903,9 +903,10 @@ public Response getExternalToolUrl(@Context ContainerRequestContext crc, @PathPa 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: " + e.getMessage()); + "Invalid JSON format in request body."); } try { @@ -928,6 +929,16 @@ public Response getExternalToolUrl(@Context ContainerRequestContext crc, @PathPa 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 + ")."); + } + // Get the current user and create a request object User user = getRequestUser(crc); DataverseRequest req = createDataverseRequest(user); @@ -981,7 +992,7 @@ public Response getExternalToolUrl(@Context ContainerRequestContext crc, @PathPa } 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: " + ex.getMessage()); + "An error occurred while generating the external tool URL."); } } From f49527b2b7c79448481f83dff8e6199b7aed2f21 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Fri, 22 Aug 2025 06:56:19 -0400 Subject: [PATCH 10/28] use this file as a test --- .../java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 9a6626fe7a3..3b89d53c3a4 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -758,8 +758,8 @@ public void testExternalToolUrlApi() { String datasetPid = JsonPath.from(createDataset.getBody().asString()).getString("data.persistentId"); // Upload a text file - String pathToTextFile = "src/test/java/edu/harvard/iq/dataverse/util/testing-readme.txt"; - Response uploadTextFile = UtilIT.uploadFileViaNative(datasetId.toString(), pathToTextFile, apiToken); + 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()); From 713fe81dc77c9d6509e784474ce36a9965c8c0a5 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Fri, 22 Aug 2025 08:26:38 -0400 Subject: [PATCH 11/28] handle no params --- .../harvard/iq/dataverse/api/Datasets.java | 27 ++++++++--------- .../edu/harvard/iq/dataverse/api/Files.java | 29 ++++++++++--------- 2 files changed, 29 insertions(+), 27 deletions(-) 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 1a05772e2e2..09ea5f9c240 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -5190,19 +5190,20 @@ public Response getDatasetExternalToolUrl(@Context ContainerRequestContext crc, String locale = null; // Parse request body for parameters - 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"); + 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 { 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 059d7587c66..54d8b593b14 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; @@ -893,22 +894,22 @@ public Response getExternalToolUrl(@Context ContainerRequestContext crc, @PathPa String locale = null; // Parse request body for parameters - - try { - jakarta.json.JsonObject jsonObject = JsonUtil.getJsonObject(jsonBody); - if (jsonObject.containsKey("preview")) { - preview = jsonObject.getBoolean("preview"); - } - if (jsonObject.containsKey("locale")) { - locale = jsonObject.getString("locale"); + 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."); } - } 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; From b2788de6217a35b7ece4ec01d84709de7b0aa1c7 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Fri, 22 Aug 2025 10:42:47 -0400 Subject: [PATCH 12/28] Fix returns --- src/main/java/edu/harvard/iq/dataverse/api/Datasets.java | 9 ++------- src/main/java/edu/harvard/iq/dataverse/api/Files.java | 8 ++------ 2 files changed, 4 insertions(+), 13 deletions(-) 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 09ea5f9c240..40565aaddde 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -5266,13 +5266,8 @@ public Response getDatasetExternalToolUrl(@Context ContainerRequestContext crc, } // Return the URL in a JSON response - return ok(Json.createObjectBuilder().add("status", ApiConstants.STATUS_OK) - .add("data", - Json.createObjectBuilder().add("toolUrl", toolUrl) - .add("toolName", externalTool.getDisplayName()) - .add("datasetId", dataset.getId()) - .add("preview", preview)) - .build()); + return ok(Json.createObjectBuilder().add("toolUrl", toolUrl).add("toolName", 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); 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 54d8b593b14..f7d35f40f63 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Files.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Files.java @@ -983,12 +983,8 @@ public Response getExternalToolUrl(@Context ContainerRequestContext crc, @PathPa } // Return the URL in a JSON response - return ok(Json.createObjectBuilder().add("status", ApiConstants.STATUS_OK) - .add("data", - Json.createObjectBuilder().add("toolUrl", toolUrl) - .add("toolName", externalTool.getDisplayName()).add("fileId", dataFile.getId()) - .add("preview", preview)) - .build()); + return ok(Json.createObjectBuilder().add("toolUrl", toolUrl).add("toolName", externalTool.getDisplayName()) + .add("fileId", dataFile.getId()).add("preview", preview)); } catch (Exception ex) { logger.log(Level.SEVERE, "Error getting external tool URL: " + ex.getMessage(), ex); From 54dabdcfa819ca801286e1a22aa39e0de384be11 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Fri, 22 Aug 2025 11:46:35 -0400 Subject: [PATCH 13/28] don't assume ids are first --- .../java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 3b89d53c3a4..6b9a6ea8bde 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -838,7 +838,8 @@ public void testExternalToolUrlApi() { datasetToolUrl.then().assertThat() .statusCode(OK.getStatusCode()) .body("status", CoreMatchers.equalTo("OK")) - .body("data.toolUrl", CoreMatchers.startsWith("http://example.org/dataset-tool?datasetId=" + datasetId)) + .body("data.toolUrl", CoreMatchers.startsWith("http://example.org/dataset-tool")) + .body("data.toolUrl", CoreMatchers.containsString("datasetId=" + datasetId)) .body("data.toolName", CoreMatchers.equalTo("Dataset API Tool")); // Extract the callback parameter @@ -877,7 +878,8 @@ public void testExternalToolUrlApi() { fileToolUrl.then().assertThat() .statusCode(OK.getStatusCode()) .body("status", CoreMatchers.equalTo("OK")) - .body("data.toolUrl", CoreMatchers.startsWith("http://example.org/text-tool?fileId=" + textFileId)) + .body("data.toolUrl", CoreMatchers.startsWith("http://example.org/text-tool")) + .body("data.toolUrl", CoreMatchers.containsString("fileId=" + textFileId)) .body("data.toolName", CoreMatchers.equalTo("Text File Tool")); // Extract the callback parameter from file tool URL From 0a076aad5410d2d153bd1d4bbd893a93a351ddd5 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Mon, 25 Aug 2025 09:00:57 -0400 Subject: [PATCH 14/28] only callback on toolurl --- .../edu/harvard/iq/dataverse/api/ExternalToolsIT.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 6b9a6ea8bde..6b550886845 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -838,9 +838,7 @@ public void testExternalToolUrlApi() { datasetToolUrl.then().assertThat() .statusCode(OK.getStatusCode()) .body("status", CoreMatchers.equalTo("OK")) - .body("data.toolUrl", CoreMatchers.startsWith("http://example.org/dataset-tool")) - .body("data.toolUrl", CoreMatchers.containsString("datasetId=" + datasetId)) - .body("data.toolName", CoreMatchers.equalTo("Dataset API Tool")); + .body("data.toolUrl", CoreMatchers.startsWith("http://example.org/dataset-tool")); // Extract the callback parameter String toolUrl = JsonPath.from(datasetToolUrl.getBody().asString()).getString("data.toolUrl"); @@ -878,9 +876,7 @@ public void testExternalToolUrlApi() { fileToolUrl.then().assertThat() .statusCode(OK.getStatusCode()) .body("status", CoreMatchers.equalTo("OK")) - .body("data.toolUrl", CoreMatchers.startsWith("http://example.org/text-tool")) - .body("data.toolUrl", CoreMatchers.containsString("fileId=" + textFileId)) - .body("data.toolName", CoreMatchers.equalTo("Text File Tool")); + .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"); From 705fdab16e64c3575771273efcc0abe5a5e28c7a Mon Sep 17 00:00:00 2001 From: qqmyers Date: Mon, 25 Aug 2025 09:01:15 -0400 Subject: [PATCH 15/28] update apitoken logic --- .../edu/harvard/iq/dataverse/api/TestApi.java | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/TestApi.java b/src/main/java/edu/harvard/iq/dataverse/api/TestApi.java index 46747b50c29..48487488b11 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/TestApi.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/TestApi.java @@ -2,7 +2,10 @@ import edu.harvard.iq.dataverse.DataFile; import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.api.auth.AuthRequired; import edu.harvard.iq.dataverse.authorization.users.ApiToken; +import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; import edu.harvard.iq.dataverse.externaltools.ExternalTool; import edu.harvard.iq.dataverse.externaltools.ExternalToolHandler; import java.util.List; @@ -13,6 +16,8 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; @@ -20,21 +25,26 @@ public class TestApi extends AbstractApiBean { @GET + @AuthRequired @Path("datasets/{id}/externalTools") - public Response getDatasetExternalToolsforFile(@PathParam("id") String idSupplied, @QueryParam("type") String typeSupplied) { + public Response getDatasetExternalToolsforFile(@Context ContainerRequestContext crc, @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()); } + + ApiToken apiToken = null; + User u = getRequestUser(crc); + apiToken = authSvc.getValidApiTokenForUser(u); + 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); @@ -46,21 +56,26 @@ public Response getDatasetExternalToolsforFile(@PathParam("id") String idSupplie } @GET + @AuthRequired @Path("datasets/{id}/externalTool/{toolId}") - public Response getExternalToolforDatasetById(@PathParam("id") String idSupplied, @PathParam("toolId") String toolId, @QueryParam("type") String typeSupplied) { + public Response getExternalToolforDatasetById(@Context ContainerRequestContext crc, @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()); } + + ApiToken apiToken = null; + User u = getRequestUser(crc); + apiToken = authSvc.getValidApiTokenForUser(u); + 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)) { From 237c7c80e6f1989809a32b3af5989211a6890317 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Mon, 25 Aug 2025 09:02:55 -0400 Subject: [PATCH 16/28] use displayName --- src/main/java/edu/harvard/iq/dataverse/api/Datasets.java | 2 +- src/main/java/edu/harvard/iq/dataverse/api/Files.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 40565aaddde..fb97c761ef1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -5266,7 +5266,7 @@ public Response getDatasetExternalToolUrl(@Context ContainerRequestContext crc, } // Return the URL in a JSON response - return ok(Json.createObjectBuilder().add("toolUrl", toolUrl).add("toolName", externalTool.getDisplayName()) + return ok(Json.createObjectBuilder().add("toolUrl", toolUrl).add("displayName", externalTool.getDisplayName()) .add("datasetId", dataset.getId()).add("preview", preview)); } catch (Exception ex) { 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 f7d35f40f63..8714903cbfe 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Files.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Files.java @@ -983,7 +983,7 @@ public Response getExternalToolUrl(@Context ContainerRequestContext crc, @PathPa } // Return the URL in a JSON response - return ok(Json.createObjectBuilder().add("toolUrl", toolUrl).add("toolName", externalTool.getDisplayName()) + return ok(Json.createObjectBuilder().add("toolUrl", toolUrl).add("displayName", externalTool.getDisplayName()) .add("fileId", dataFile.getId()).add("preview", preview)); } catch (Exception ex) { From 15149492f3f7a669050b63eb6383ca5c06083be1 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Tue, 2 Sep 2025 17:02:42 -0400 Subject: [PATCH 17/28] delete /api/admin/test but add some TODOs #11760 Enough functionality has been added in #11760 that it should be save to delete all of /api/admin/test (TestApi.java) because it was only testing external tools. However, if we incorporate this change into that PR we should do the following: - fix the backend to not return a toolUrl when it shouldn't (see testFileLevelToolWithAuxFileReq) - consider how we aren't exercising findDatasetToolsByType and findFileToolsByType anymore. They are used by JSF. - consider how we don't return the following fields when getting the toolUrl: scope, types, contentType - remove commented code that's just there for review --- .../edu/harvard/iq/dataverse/api/TestApi.java | 144 ------------------ .../iq/dataverse/api/ExternalToolsIT.java | 84 +++++----- .../edu/harvard/iq/dataverse/api/UtilIT.java | 142 +++++++++-------- 3 files changed, 128 insertions(+), 242 deletions(-) delete mode 100644 src/main/java/edu/harvard/iq/dataverse/api/TestApi.java 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 48487488b11..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/TestApi.java +++ /dev/null @@ -1,144 +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.api.auth.AuthRequired; -import edu.harvard.iq.dataverse.authorization.users.ApiToken; -import edu.harvard.iq.dataverse.authorization.users.User; -import edu.harvard.iq.dataverse.engine.command.DataverseRequest; -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.container.ContainerRequestContext; -import jakarta.ws.rs.core.Context; -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 - @AuthRequired - @Path("datasets/{id}/externalTools") - public Response getDatasetExternalToolsforFile(@Context ContainerRequestContext crc, @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()); - } - - ApiToken apiToken = null; - User u = getRequestUser(crc); - apiToken = authSvc.getValidApiTokenForUser(u); - - Dataset dataset; - try { - dataset = findDatasetOrDie(idSupplied); - JsonArrayBuilder tools = Json.createArrayBuilder(); - List datasetTools = externalToolService.findDatasetToolsByType(type); - for (ExternalTool tool : datasetTools) { - 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 - @AuthRequired - @Path("datasets/{id}/externalTool/{toolId}") - public Response getExternalToolforDatasetById(@Context ContainerRequestContext crc, @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()); - } - - ApiToken apiToken = null; - User u = getRequestUser(crc); - apiToken = authSvc.getValidApiTokenForUser(u); - - Dataset dataset; - try { - dataset = findDatasetOrDie(idSupplied); - JsonArrayBuilder tools = Json.createArrayBuilder(); - List datasetTools = externalToolService.findDatasetToolsByType(type); - for (ExternalTool tool : datasetTools) { - 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/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java index 6b550886845..4945a11427d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -96,14 +96,15 @@ public void testExternalToolsNonAdminEndpoint() { .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.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); // TODO: delete + Response getExternalToolsByDatasetId = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken, null); getExternalToolsByDatasetId.prettyPrint(); getExternalToolsByDatasetId.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.scope", CoreMatchers.equalTo("dataset")) +// .body("data.types[0]", CoreMatchers.equalTo("configure")) + .body("data.toolUrl", CoreMatchers.equalTo("https://datasetconfigurator.com?datasetPid=" + datasetPid)); Response getExternalTools = UtilIT.getExternalTools(apiToken); getExternalTools.prettyPrint(); @@ -119,7 +120,9 @@ public void testExternalToolsNonAdminEndpoint() { getExternalTools = UtilIT.getExternalTools(apiToken); getExternalTools.then().assertThat() .statusCode(OK.getStatusCode()); - getExternalToolsByDatasetId = UtilIT.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); + +// getExternalToolsByDatasetId = UtilIT.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); // TODO: delete + getExternalToolsByDatasetId = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken, null); getExternalToolsByDatasetId.prettyPrint(); getExternalToolsByDatasetId.then().assertThat() .statusCode(OK.getStatusCode()); @@ -216,30 +219,31 @@ 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].")); + // TODO: delete this test +// 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.getExternalToolForFileById(tabularFileId.toString(), "explore", apiToken, toolIdString); // TODO: delete + 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.scope", CoreMatchers.equalTo("file")) +// .body("data.contentType", CoreMatchers.equalTo("text/tab-separated-values")) + .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); @@ -306,18 +310,20 @@ 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()); + // TODO: delete this test +// 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.scope", CoreMatchers.equalTo("dataset")) + .body("data.toolUrl", CoreMatchers.equalTo("http://datasettool1.com?datasetPid=" + datasetPid + "&key=" + apiToken)) .statusCode(OK.getStatusCode()); //Delete the tool added by this test... @@ -379,13 +385,14 @@ 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.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); // TODO: delete + 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.scope", CoreMatchers.equalTo("dataset")) +// .body("data.types[0]", CoreMatchers.equalTo("configure")) + .body("data.toolUrl", CoreMatchers.equalTo("https://datasetconfigurator.com?datasetPid=" + datasetPid)) .statusCode(OK.getStatusCode()); //Delete the tool added by this test... @@ -709,20 +716,23 @@ 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.getExternalToolsForFile(falseHdf5.toString(), "preview", apiToken); // TODO: delete + Response getToolsForFalseHdf5 = UtilIT.getFileToolUrl(falseHdf5.toString(), toolId.toString(), apiToken, null); getToolsForFalseHdf5.prettyPrint(); + // TODO: update this assertion once the backend is fixed. The backend should not be offering a tool in this case. getToolsForFalseHdf5.then().assertThat() .statusCode(OK.getStatusCode()) .body("data", Matchers.hasSize(0)); // 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.getExternalToolForFileById(trueHdf5.toString(), "preview", apiToken, toolId.toString()); // TODO: delete + 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")); +// .body("data.scope", CoreMatchers.equalTo("file")) +// .body("data.contentType", CoreMatchers.equalTo("application/x-hdf5")); //Delete the tool added by this test... Response deleteExternalTool = UtilIT.deleteExternalTool(toolId); 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 fe4f71669b1..97078cfbf9d 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,81 @@ 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); - } + /** + * TODO: delete all this commented-out code. It's here while we're reviewing + * https://github.com/IQSS/dataverse/pull/11760 but all these methods no + * longer exist once TestApi.java, which we are proposing we delete, is + * gone. Some comments on these methods: + * + * - getExternalToolsForDataset and getExternalToolForDatasetById: These + * methods were exercising externalToolService.findDatasetToolsByType. Now + * this code is only exercised by JSF. Does this matter? Will the SPA use + * this method? + * + * - getExternalToolForFile and getExternalToolForFileById: Same but these + * methods were exercising externalToolService.findFileToolsByType (byType + * for files instead of datasets). Now only JSF calls into this method. + */ +// +// 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() @@ -4978,7 +4994,9 @@ public static Response getTemplates(String dataverseAlias, String apiToken) { * @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) + * @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) { @@ -4998,7 +5016,9 @@ public static Response getDatasetToolUrl(String datasetId, String toolId, String * @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) + * @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) { From 01c714040db9d9e252d23283f8905ed1b565de04 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Wed, 3 Sep 2025 14:06:41 -0400 Subject: [PATCH 18/28] add meetsReqs --- src/main/java/edu/harvard/iq/dataverse/api/Files.java | 4 ++++ 1 file changed, 4 insertions(+) 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 8714903cbfe..4ea9087b325 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Files.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Files.java @@ -940,6 +940,10 @@ public Response getExternalToolUrl(@Context ContainerRequestContext crc, @PathPa ") 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); From 65231d6d363348ea84b66bcf80a0faeaee3fd463 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Wed, 3 Sep 2025 14:11:23 -0400 Subject: [PATCH 19/28] doc changes per review --- doc/sphinx-guides/source/api/native-api.rst | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 7c2b892e8b3..194b697b25f 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -3453,10 +3453,10 @@ Authentication is required for draft or deaccessioned datasets and the user must export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx export SERVER_URL=https://demo.dataverse.org - export DATASET_ID=1234 + 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/$DATASET_ID/externalTool/$TOOL_ID/toolUrl" \ + 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"}' @@ -3464,15 +3464,17 @@ 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/1234/externalTool/42/toolUrl" \ + 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 @@ -5358,10 +5360,12 @@ The fully expanded example above (without environment variables) looks like this -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 From e8f8aa798871f6e8eb7c6ed1e4b7be721a42b6dc Mon Sep 17 00:00:00 2001 From: qqmyers Date: Wed, 3 Sep 2025 14:13:19 -0400 Subject: [PATCH 20/28] Apply suggestions from code review Co-authored-by: Philip Durbin --- doc/sphinx-guides/source/api/native-api.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 7c2b892e8b3..f0626033a61 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -3495,7 +3495,7 @@ Example response: 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. @@ -5336,7 +5336,7 @@ 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. +Authentication is required for draft, restricted, embargoed, or expired (retention period) files; the user must have appropriate permissions. .. code-block:: bash From 2f9940f8cbfd1c3a5e804645c5952ef80498c264 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Wed, 3 Sep 2025 14:33:48 -0400 Subject: [PATCH 21/28] add requirements to etools apis and fix json formatting --- doc/release-notes/11760-tool url apis.md | 5 ++++- doc/sphinx-guides/source/api/changelog.rst | 1 + .../harvard/iq/dataverse/externaltools/ExternalTool.java | 9 +++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/doc/release-notes/11760-tool url apis.md b/doc/release-notes/11760-tool url apis.md index 1d654596a0d..a54641a29e6 100644 --- a/doc/release-notes/11760-tool url apis.md +++ b/doc/release-notes/11760-tool url apis.md @@ -4,4 +4,7 @@ New API calls have been added to retrieve the URLs needed to launch external too 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. \ No newline at end of file +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..b4d0c29f12b 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 confiugured "requirements" are included. v6.7 ---- 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; } From dbd7f8e97042a8462befe7ae7e3827e564bf2a9c Mon Sep 17 00:00:00 2001 From: qqmyers Date: Wed, 3 Sep 2025 15:46:26 -0400 Subject: [PATCH 22/28] update tests --- .../iq/dataverse/api/ExternalToolsIT.java | 80 +++++++------------ .../edu/harvard/iq/dataverse/api/UtilIT.java | 75 ----------------- 2 files changed, 31 insertions(+), 124 deletions(-) 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 4945a11427d..a17cbeeb308 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -38,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() @@ -89,45 +92,51 @@ 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()); // TODO: delete - Response getExternalToolsByDatasetId = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken, null); - 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.toolUrl", CoreMatchers.equalTo("https://datasetconfigurator.com?datasetPid=" + datasetPid)); + .body("data.scope", CoreMatchers.equalTo("dataset")) + .body("data.types[0]", CoreMatchers.equalTo("configure")) + .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()); // TODO: delete - getExternalToolsByDatasetId = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken, null); - getExternalToolsByDatasetId.prettyPrint(); - getExternalToolsByDatasetId.then().assertThat() - .statusCode(OK.getStatusCode()); + // but can't get a launch url (which would need to be signed for a draft dataset) + getExternalToolUrl = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken, null); + getExternalToolUrl.then().assertThat() + .statusCode(FORBIDDEN.getStatusCode()); - //Add by non-superuser will fail + //Add by non-superuser will also fail addExternalTool = UtilIT.addExternalTool(JsonUtil.getJsonObject(toolManifest), apiToken); addExternalTool.then().assertThat() .statusCode(FORBIDDEN.getStatusCode()) @@ -219,23 +228,13 @@ public void testFileLevelTool1() { .body("data.scope", CoreMatchers.equalTo("file")) .statusCode(OK.getStatusCode()); - // TODO: delete this test -// 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); // TODO: delete 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.toolUrl", CoreMatchers.equalTo("http://awesometool.com?fileid=" + tabularFileId + "&key=" + apiToken)); Response getExternalToolsForJuptyerNotebooks = UtilIT.getFileToolUrl(jupyterNotebookFileId.toString(), toolId.toString(), apiToken, null); @@ -310,19 +309,10 @@ public void testDatasetLevelTool1() { Long toolId = JsonPath.from(addExternalTool.getBody().asString()).getLong("data.id"); - // TODO: delete this test -// 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.toolUrl", CoreMatchers.equalTo("http://datasettool1.com?datasetPid=" + datasetPid + "&key=" + apiToken)) .statusCode(OK.getStatusCode()); @@ -385,13 +375,10 @@ 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()); // TODO: delete 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.toolUrl", CoreMatchers.equalTo("https://datasetconfigurator.com?datasetPid=" + datasetPid)) .statusCode(OK.getStatusCode()); @@ -716,23 +703,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); // TODO: delete Response getToolsForFalseHdf5 = UtilIT.getFileToolUrl(falseHdf5.toString(), toolId.toString(), apiToken, null); getToolsForFalseHdf5.prettyPrint(); - // TODO: update this assertion once the backend is fixed. The backend should not be offering a tool in this case. getToolsForFalseHdf5.then().assertThat() - .statusCode(OK.getStatusCode()) - .body("data", Matchers.hasSize(0)); + .statusCode(BAD_REQUEST.getStatusCode()) + .body("data.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()); // TODO: delete 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")); //Delete the tool added by this test... Response deleteExternalTool = UtilIT.deleteExternalTool(toolId); 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 97078cfbf9d..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,81 +2795,6 @@ static Response deleteExternalTool(long externalToolid, String apiToken) { return requestSpecification.delete("/api/externalTools/" + externalToolid); } - /** - * TODO: delete all this commented-out code. It's here while we're reviewing - * https://github.com/IQSS/dataverse/pull/11760 but all these methods no - * longer exist once TestApi.java, which we are proposing we delete, is - * gone. Some comments on these methods: - * - * - getExternalToolsForDataset and getExternalToolForDatasetById: These - * methods were exercising externalToolService.findDatasetToolsByType. Now - * this code is only exercised by JSF. Does this matter? Will the SPA use - * this method? - * - * - getExternalToolForFile and getExternalToolForFileById: Same but these - * methods were exercising externalToolService.findFileToolsByType (byType - * for files instead of datasets). Now only JSF calls into this method. - */ -// -// 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() From 5735be7dfa33eabdcb0056d85046d24fa1f0e3a4 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Wed, 3 Sep 2025 15:48:21 -0400 Subject: [PATCH 23/28] add note --- src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java | 1 + 1 file changed, 1 insertion(+) 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(); } From c799b66f051ce2b0107e95d980a5e9f12f4453fd Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 4 Sep 2025 08:51:06 -0400 Subject: [PATCH 24/28] fix test --- .../harvard/iq/dataverse/externaltools/ExternalToolTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"); } From 653be76f94ede25ff320003d3101504467f77a6d Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 4 Sep 2025 13:22:25 -0400 Subject: [PATCH 25/28] add user who can't see draft dataset --- .../harvard/iq/dataverse/api/ExternalToolsIT.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 a17cbeeb308..8deceab13ca 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -127,15 +127,25 @@ public void testExternalToolsLifecycle() { // non superuser can also view tools UtilIT.setSuperuserStatus(username, false); + getExternalTools = UtilIT.getExternalTools(apiToken); getExternalTools.then().assertThat() .statusCode(OK.getStatusCode()); - // but can't get a launch url (which would need to be signed for a draft dataset) - getExternalToolUrl = UtilIT.getDatasetToolUrl(datasetId.toString(), toolId.toString(), apiToken, null); + //Create user who can't see draft dataset + Response createUser2 = UtilIT.createRandomUser(); + createUser2.prettyPrint(); + createUser2.then().assertThat() + .statusCode(OK.getStatusCode()); + String username2 = UtilIT.getUsernameFromResponse(createUser); + String apiToken2 = UtilIT.getApiTokenFromResponse(createUser); + + // 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()); + // 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() @@ -154,6 +164,7 @@ public void testExternalToolsLifecycle() { deleteExternalTool.prettyPrint(); deleteExternalTool.then().assertThat() .statusCode(OK.getStatusCode()); + } @Test From 932b01454b2247dcb584a5e5bb3f50ae398f15fa Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 4 Sep 2025 13:22:34 -0400 Subject: [PATCH 26/28] fix path for error msg --- src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8deceab13ca..a46d0f62fdc 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -718,7 +718,7 @@ public void testFileLevelToolWithAuxFileReq() throws IOException { getToolsForFalseHdf5.prettyPrint(); getToolsForFalseHdf5.then().assertThat() .statusCode(BAD_REQUEST.getStatusCode()) - .body("data.message", CoreMatchers.equalTo("External tool requirements not met for this file.")); + .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.getFileToolUrl(trueHdf5.toString(), toolId.toString(), apiToken, null); From 6f8ee1bd20ed6877290dd6225cdadc27c6929d91 Mon Sep 17 00:00:00 2001 From: qqmyers Date: Thu, 4 Sep 2025 16:13:19 -0400 Subject: [PATCH 27/28] typo - need user 2 --- .../java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a46d0f62fdc..6637ea290c1 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -137,8 +137,8 @@ public void testExternalToolsLifecycle() { createUser2.prettyPrint(); createUser2.then().assertThat() .statusCode(OK.getStatusCode()); - String username2 = UtilIT.getUsernameFromResponse(createUser); - String apiToken2 = UtilIT.getApiTokenFromResponse(createUser); + 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); From 2b9bab938799dedc44f75efc157cb3944824a4ee Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Mon, 8 Sep 2025 13:48:15 -0400 Subject: [PATCH 28/28] typo --- doc/sphinx-guides/source/api/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/changelog.rst b/doc/sphinx-guides/source/api/changelog.rst index b4d0c29f12b..5be6c78adce 100644 --- a/doc/sphinx-guides/source/api/changelog.rst +++ b/doc/sphinx-guides/source/api/changelog.rst @@ -13,7 +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 confiugured "requirements" are included. +- 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 ----