diff --git a/doc/release-notes/10930-marketplace-external-tools-apis.md b/doc/release-notes/10930-marketplace-external-tools-apis.md new file mode 100644 index 00000000000..e3350a8b2d2 --- /dev/null +++ b/doc/release-notes/10930-marketplace-external-tools-apis.md @@ -0,0 +1,14 @@ +## New APIs for External Tools Registration for Marketplace + +New API base path /api/externalTools created that mimics the admin APIs /api/admin/externalTools. These new add and delete apis require an authenticated superuser token. + +Example: +``` + API_TOKEN='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + export TOOL_ID=1 + + curl http://localhost:8080/api/externalTools + curl http://localhost:8080/api/externalTools/$TOOL_ID + curl -s -H "X-Dataverse-key:$API_TOKEN" -X POST -H 'Content-type: application/json' http://localhost:8080/api/externalTools --upload-file fabulousFileTool.json + curl -s -H "X-Dataverse-key:$API_TOKEN" -X DELETE http://localhost:8080/api/externalTools/$TOOL_ID +``` diff --git a/doc/sphinx-guides/source/admin/external-tools.rst b/doc/sphinx-guides/source/admin/external-tools.rst index 346ca0b15ee..c3e71c13ac6 100644 --- a/doc/sphinx-guides/source/admin/external-tools.rst +++ b/doc/sphinx-guides/source/admin/external-tools.rst @@ -35,7 +35,13 @@ Configure the tool with the curl command below, making sure to replace the ``fab .. code-block:: bash - curl -X POST -H 'Content-type: application/json' http://localhost:8080/api/admin/externalTools --upload-file fabulousFileTool.json + curl -X POST -H 'Content-type: application/json' http://localhost:8080/api/admin/externalTools --upload-file fabulousFileTool.json + +This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + +.. code-block:: bash + + curl -s -H "X-Dataverse-key:$API_TOKEN" -X POST -H 'Content-type: application/json' http://localhost:8080/api/externalTools --upload-file fabulousFileTool.json Listing All External Tools in a Dataverse Installation ++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -46,6 +52,12 @@ To list all the external tools that are available in a Dataverse installation: curl http://localhost:8080/api/admin/externalTools +This API is open to any user. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + +.. code-block:: bash + + curl http://localhost:8080/api/externalTools + Showing an External Tool in a Dataverse Installation ++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -56,6 +68,12 @@ To show one of the external tools that are available in a Dataverse installation export TOOL_ID=1 curl http://localhost:8080/api/admin/externalTools/$TOOL_ID +This API is open to any user. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + +.. code-block:: bash + + curl http://localhost:8080/api/externalTools/$TOOL_ID + Removing an External Tool From a Dataverse Installation +++++++++++++++++++++++++++++++++++++++++++++++++++++++ @@ -66,6 +84,12 @@ Assuming the external tool database id is "1", remove it with the following comm export TOOL_ID=1 curl -X DELETE http://localhost:8080/api/admin/externalTools/$TOOL_ID +This API is Superuser only. Note the endpoint difference (/api/externalTools instead of /api/admin/externalTools). + +.. code-block:: bash + + curl -s -H "X-Dataverse-key:$API_TOKEN" -X DELETE http://localhost:8080/api/externalTools/$TOOL_ID + .. _testing-external-tools: Testing External Tools diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java b/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java new file mode 100644 index 00000000000..92139d86caf --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/ExternalToolsApi.java @@ -0,0 +1,58 @@ +package edu.harvard.iq.dataverse.api; + +import edu.harvard.iq.dataverse.api.auth.AuthRequired; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; + +@Path("externalTools") +public class ExternalToolsApi extends AbstractApiBean { + + @Inject + ExternalTools externalTools; + + @GET + public Response getExternalTools() { + return externalTools.getExternalTools(); + } + + @GET + @Path("{id}") + public Response getExternalTool(@PathParam("id") long externalToolIdFromUser) { + return externalTools.getExternalTool(externalToolIdFromUser); + } + + @POST + @AuthRequired + public Response addExternalTool(@Context ContainerRequestContext crc, String manifest) { + Response notAuthorized = authorize(crc); + return notAuthorized == null ? externalTools.addExternalTool(manifest) : notAuthorized; + } + + @DELETE + @AuthRequired + @Path("{id}") + public Response deleteExternalTool(@Context ContainerRequestContext crc, @PathParam("id") long externalToolIdFromUser) { + Response notAuthorized = authorize(crc); + return notAuthorized == null ? externalTools.deleteExternalTool(externalToolIdFromUser) : notAuthorized; + } + + private Response authorize(ContainerRequestContext crc) { + try { + AuthenticatedUser user = getRequestAuthenticatedUserOrDie(crc); + if (!user.isSuperuser()) { + return error(Response.Status.FORBIDDEN, "Superusers only."); + } + } catch (WrappedResponse ex) { + return error(Response.Status.FORBIDDEN, "Superusers only."); + } + return null; + } +} 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 22abf6fa2e3..1956e0eb8df 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ExternalToolsIT.java @@ -11,11 +11,11 @@ import java.nio.file.Paths; import jakarta.json.Json; import jakarta.json.JsonArray; -import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; import jakarta.json.JsonReader; import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; import static jakarta.ws.rs.core.Response.Status.CREATED; +import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; import static jakarta.ws.rs.core.Response.Status.OK; import org.hamcrest.CoreMatchers; import org.hamcrest.Matchers; @@ -37,6 +37,108 @@ public void testGetExternalTools() { getExternalTools.prettyPrint(); } + @Test + public void testExternalToolsNonAdminEndpoint() { + Response createUser = UtilIT.createRandomUser(); + createUser.prettyPrint(); + createUser.then().assertThat() + .statusCode(OK.getStatusCode()); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + UtilIT.setSuperuserStatus(username, true); + + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.prettyPrint(); + createDataverseResponse.then().assertThat() + .statusCode(CREATED.getStatusCode()); + + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + Response createDataset = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, apiToken); + createDataset.prettyPrint(); + createDataset.then().assertThat() + .statusCode(CREATED.getStatusCode()); + + Integer datasetId = JsonPath.from(createDataset.getBody().asString()).getInt("data.id"); + String datasetPid = JsonPath.from(createDataset.getBody().asString()).getString("data.persistentId"); + + String toolManifest = """ +{ + "displayName": "Dataset Configurator", + "description": "Slices! Dices! More info.", + "types": [ + "configure" + ], + "scope": "dataset", + "toolUrl": "https://datasetconfigurator.com", + "toolParameters": { + "queryParameters": [ + { + "datasetPid": "{datasetPid}" + }, + { + "localeCode": "{localeCode}" + } + ] + } + } +"""; + + Response addExternalTool = UtilIT.addExternalTool(JsonUtil.getJsonObject(toolManifest), apiToken); + addExternalTool.prettyPrint(); + addExternalTool.then().assertThat() + .statusCode(OK.getStatusCode()) + .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()); + 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)) + .statusCode(OK.getStatusCode()); + + 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()); + + // non superuser can only view tools + UtilIT.setSuperuserStatus(username, false); + getExternalTools = UtilIT.getExternalTools(apiToken); + getExternalTools.then().assertThat() + .statusCode(OK.getStatusCode()); + getExternalToolsByDatasetId = UtilIT.getExternalToolForDatasetById(datasetId.toString(), "configure", apiToken, toolId.toString()); + getExternalToolsByDatasetId.prettyPrint(); + getExternalToolsByDatasetId.then().assertThat() + .statusCode(OK.getStatusCode()); + + //Add by non-superuser will fail + addExternalTool = UtilIT.addExternalTool(JsonUtil.getJsonObject(toolManifest), apiToken); + addExternalTool.then().assertThat() + .statusCode(FORBIDDEN.getStatusCode()) + .body("message", CoreMatchers.equalTo("Superusers only.")); + + //Delete by non-superuser will fail + Response deleteExternalTool = UtilIT.deleteExternalTool(toolId, apiToken); + deleteExternalTool.then().assertThat() + .statusCode(FORBIDDEN.getStatusCode()) + .body("message", CoreMatchers.equalTo("Superusers only.")); + + //Delete the tool added by this test... + UtilIT.setSuperuserStatus(username, true); + deleteExternalTool = UtilIT.deleteExternalTool(toolId, apiToken); + deleteExternalTool.prettyPrint(); + deleteExternalTool.then().assertThat() + .statusCode(OK.getStatusCode()); + } + @Test public void testFileLevelTool1() { 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 2b8b2ce45e3..13554793108 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -2539,6 +2539,42 @@ static Response deleteExternalTool(long externalToolid) { .delete("/api/admin/externalTools/" + externalToolid); } +// ExternalTools with token + static Response getExternalTools(String apiToken) { + RequestSpecification requestSpecification = given(); + if (apiToken != null) { + requestSpecification.header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); + } + return requestSpecification.get("/api/externalTools"); + } + + static Response getExternalTool(long id, String apiToken) { + RequestSpecification requestSpecification = given(); + if (apiToken != null) { + requestSpecification.header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); + } + return requestSpecification.get("/api/externalTools/" + id); + } + + static Response addExternalTool(JsonObject jsonObject, String apiToken) { + RequestSpecification requestSpecification = given(); + if (apiToken != null) { + requestSpecification.header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); + } + return requestSpecification + .body(jsonObject.toString()) + .contentType(ContentType.JSON) + .post("/api/externalTools"); + } + + static Response deleteExternalTool(long externalToolid, String apiToken) { + RequestSpecification requestSpecification = given(); + if (apiToken != null) { + requestSpecification.header(UtilIT.API_TOKEN_HTTP_HEADER, 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.