From 985b3494cf554861ba48ae88406a8fae647fbb04 Mon Sep 17 00:00:00 2001 From: "Balazs E. Pataki" Date: Tue, 2 Jun 2026 22:35:52 +0200 Subject: [PATCH] Improve Dataverse REST OpenAPI source annotations Add source-level OpenAPI annotations across Dataverse REST resources, including class tags, operation summaries/descriptions, parameter and request-body docs, API key security metadata, and response schema fixes for generated OpenAPI/MCP tooling. #12437 describes this effort in more details --- .../edu/harvard/iq/dataverse/api/Access.java | 103 +++- .../edu/harvard/iq/dataverse/api/Admin.java | 444 ++++++++++++-- .../iq/dataverse/api/ApiConfiguration.java | 10 + .../harvard/iq/dataverse/api/BatchImport.java | 41 +- .../iq/dataverse/api/BuiltinUsers.java | 47 +- .../harvard/iq/dataverse/api/DataTagsAPI.java | 15 +- .../dataverse/api/DatasetFieldServiceApi.java | 37 +- .../iq/dataverse/api/DatasetFields.java | 5 + .../harvard/iq/dataverse/api/Datasets.java | 289 ++++++++- .../dataverse/api/DataverseFeaturedItems.java | 22 +- .../harvard/iq/dataverse/api/Dataverses.java | 563 ++++++++++++++++-- .../edu/harvard/iq/dataverse/api/EditDDI.java | 15 +- .../iq/dataverse/api/ExternalTools.java | 25 +- .../iq/dataverse/api/ExternalToolsApi.java | 28 +- .../harvard/iq/dataverse/api/FeedbackApi.java | 12 +- .../edu/harvard/iq/dataverse/api/Files.java | 122 +++- .../edu/harvard/iq/dataverse/api/Groups.java | 75 ++- .../harvard/iq/dataverse/api/Guestbooks.java | 41 +- .../iq/dataverse/api/HarvestingClients.java | 55 +- .../iq/dataverse/api/HarvestingServer.java | 57 +- .../edu/harvard/iq/dataverse/api/Index.java | 46 +- .../edu/harvard/iq/dataverse/api/Info.java | 33 +- .../harvard/iq/dataverse/api/Licenses.java | 55 +- .../iq/dataverse/api/LocalContexts.java | 22 +- .../edu/harvard/iq/dataverse/api/Logout.java | 5 + .../edu/harvard/iq/dataverse/api/Mail.java | 5 + .../iq/dataverse/api/MakeDataCountApi.java | 48 +- .../harvard/iq/dataverse/api/Metadata.java | 26 +- .../iq/dataverse/api/MetadataBlocks.java | 19 +- .../edu/harvard/iq/dataverse/api/Metrics.java | 288 +++++++-- .../iq/dataverse/api/Notifications.java | 54 +- .../edu/harvard/iq/dataverse/api/Pids.java | 38 +- .../edu/harvard/iq/dataverse/api/Prov.java | 49 +- .../edu/harvard/iq/dataverse/api/Roles.java | 28 +- .../iq/dataverse/api/SavedSearches.java | 43 +- .../edu/harvard/iq/dataverse/api/Search.java | 5 + .../iq/dataverse/api/SendFeedbackAPI.java | 14 +- .../edu/harvard/iq/dataverse/api/SiteMap.java | 5 + .../iq/dataverse/api/StorageSites.java | 33 +- .../edu/harvard/iq/dataverse/api/Users.java | 78 ++- .../harvard/iq/dataverse/api/Workflows.java | 13 +- .../iq/dataverse/api/WorkflowsAdmin.java | 57 +- .../api/batchjob/BatchJobResource.java | 20 +- .../api/batchjob/FileRecordJobResource.java | 12 + 44 files changed, 2655 insertions(+), 347 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Access.java b/src/main/java/edu/harvard/iq/dataverse/api/Access.java index c3c74f49019..dc8eb4eb57c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Access.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Access.java @@ -45,9 +45,11 @@ import jakarta.ws.rs.core.*; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.glassfish.jersey.media.multipart.FormDataBodyPart; import org.glassfish.jersey.media.multipart.FormDataParam; @@ -88,6 +90,7 @@ */ @Path("access") +@Tag(name = "Access", description = "Download files, bundles, citations, metadata, and access-related file assets.") public class Access extends AbstractApiBean { private static final Logger logger = Logger.getLogger(Access.class.getCanonicalName()); @@ -162,8 +165,18 @@ public Response datafileCitation(@Context ContainerRequestContext crc, @AuthRequired @Path("datafile/bundle/{fileId}") @Produces({"application/zip"}) - public BundleDownloadInstance datafileBundle(@Context ContainerRequestContext crc, @PathParam("fileId") String fileId, @QueryParam("fileMetadataId") Long fileMetadataId, - @QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids, + @Operation(summary = "Build a file bundle", + description = "Streams a ZIP bundle for a data file, including citation exports and optional tabular metadata when available.") + @SecurityRequirement(name = "DataverseApiKey") + public BundleDownloadInstance datafileBundle(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id or persistent identifier for the bundle.", required = true) + @PathParam("fileId") String fileId, + @Parameter(description = "File metadata id used to select a specific file metadata record.") + @QueryParam("fileMetadataId") Long fileMetadataId, + @Parameter(description = "Whether guestbook records have already been written for this download.") + @QueryParam("gbrecs") boolean gbrecs, + @Parameter(description = "Guestbook response id list supplied by the user interface.") + @QueryParam("gbrids") String gbrids, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ { DataFile df = findDataFileOrDieWrapper(fileId); @@ -224,8 +237,21 @@ public BundleDownloadInstance datafileBundle(@Context ContainerRequestContext cr @AuthRequired @Path("datafile/bundle/{fileId}") @Produces({"application/zip"}) - public BundleDownloadInstance datafileBundleWithGuestbookResponse(@Context ContainerRequestContext crc, @PathParam("fileId") String fileId, @QueryParam("fileMetadataId") Long fileMetadataId, @QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids, - @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response, String jsonBody) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ { + @Operation(summary = "Submit guestbook response for a file bundle", + description = "Records the supplied guestbook response and then streams the ZIP bundle for a data file.") + @SecurityRequirement(name = "DataverseApiKey") + public BundleDownloadInstance datafileBundleWithGuestbookResponse(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id or persistent identifier for the bundle.", required = true) + @PathParam("fileId") String fileId, + @Parameter(description = "File metadata id used to select a specific file metadata record.") + @QueryParam("fileMetadataId") Long fileMetadataId, + @Parameter(description = "Whether guestbook records have already been written for this download.") + @QueryParam("gbrecs") boolean gbrecs, + @Parameter(description = "Guestbook response id list supplied by the user interface.") + @QueryParam("gbrids") String gbrids, + @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response, + @RequestBody(description = "Guestbook response JSON for the requested data file.") + String jsonBody) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ { processDatafileWithGuestbookResponse(crc, headers, fileId, uriInfo, gbrecs, jsonBody); // JSF UI passes the guestbook response id(s) in thus this qp can be removed when JSF is removed @@ -406,8 +432,17 @@ public Response datafile(@Context ContainerRequestContext crc, @PathParam("fileI @AuthRequired @Path("datafile/{fileId:.+}") @Produces({"application/json"}) - public Response datafileWithGuestbookResponse(@Context ContainerRequestContext crc, @PathParam("fileId") String fileId, - @QueryParam("gbrecs") boolean gbrecs, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response, String jsonBody) { + @Operation(summary = "Submit guestbook response for a data file", + description = "Records the supplied guestbook response and returns access details for a data file download.") + @SecurityRequirement(name = "DataverseApiKey") + public Response datafileWithGuestbookResponse(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id, persistent identifier, or path-style file reference.", required = true) + @PathParam("fileId") String fileId, + @Parameter(description = "Whether guestbook records have already been written for this download.") + @QueryParam("gbrecs") boolean gbrecs, + @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response, + @RequestBody(description = "Guestbook response JSON for the requested data file.") + String jsonBody) { fileId = normalizeFileId(fileId); return processDatafileWithGuestbookResponse(crc, headers, fileId, uriInfo, gbrecs, jsonBody); @@ -579,7 +614,19 @@ private Response returnSignedUrl(ContainerRequestContext crc, UriInfo uriInfo, U @AuthRequired @Path("datafile/{fileId}/metadata") @Produces({"text/xml"}) - public String tabularDatafileMetadata(@Context ContainerRequestContext crc, @PathParam("fileId") String fileId, @QueryParam("fileMetadataId") Long fileMetadataId, @QueryParam("exclude") String exclude, @QueryParam("include") String include, @Context HttpHeaders header, @Context HttpServletResponse response) throws NotFoundException, ServiceUnavailableException /*, PermissionDeniedException, AuthorizationRequiredException*/ { + @Operation(summary = "Export tabular file metadata", + description = "Streams tabular data file metadata in the default DDI XML format.") + @SecurityRequirement(name = "DataverseApiKey") + public String tabularDatafileMetadata(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id or persistent identifier for the tabular file.", required = true) + @PathParam("fileId") String fileId, + @Parameter(description = "File metadata id used to select a specific file metadata record.") + @QueryParam("fileMetadataId") Long fileMetadataId, + @Parameter(description = "Comma-separated metadata sections to exclude from the export.") + @QueryParam("exclude") String exclude, + @Parameter(description = "Comma-separated metadata sections to include in the export.") + @QueryParam("include") String include, + @Context HttpHeaders header, @Context HttpServletResponse response) throws NotFoundException, ServiceUnavailableException /*, PermissionDeniedException, AuthorizationRequiredException*/ { return tabularDatafileMetadataDDI(crc, fileId, fileMetadataId, exclude, include, header, response); } @@ -591,7 +638,19 @@ public String tabularDatafileMetadata(@Context ContainerRequestContext crc, @Pat @AuthRequired @GET @Produces({"text/xml"}) - public String tabularDatafileMetadataDDI(@Context ContainerRequestContext crc, @PathParam("fileId") String fileId, @QueryParam("fileMetadataId") Long fileMetadataId, @QueryParam("exclude") String exclude, @QueryParam("include") String include, @Context HttpHeaders header, @Context HttpServletResponse response) throws NotFoundException, ServiceUnavailableException /*, PermissionDeniedException, AuthorizationRequiredException*/ { + @Operation(summary = "Export tabular file metadata as DDI", + description = "Streams DDI XML metadata for a tabular data file.") + @SecurityRequirement(name = "DataverseApiKey") + public String tabularDatafileMetadataDDI(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id or persistent identifier for the tabular file.", required = true) + @PathParam("fileId") String fileId, + @Parameter(description = "File metadata id used to select a specific file metadata record.") + @QueryParam("fileMetadataId") Long fileMetadataId, + @Parameter(description = "Comma-separated metadata sections to exclude from the export.") + @QueryParam("exclude") String exclude, + @Parameter(description = "Comma-separated metadata sections to include in the export.") + @QueryParam("include") String include, + @Context HttpHeaders header, @Context HttpServletResponse response) throws NotFoundException, ServiceUnavailableException /*, PermissionDeniedException, AuthorizationRequiredException*/ { String retValue = ""; DataFile dataFile = null; @@ -799,7 +858,17 @@ public DownloadInstance downloadAuxiliaryFile(@Context ContainerRequestContext c @Path("datafiles") @Consumes("text/plain") @Produces({ "application/zip" }) - public Response postDownloadDatafiles(@Context ContainerRequestContext crc, String body, @QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) throws WebApplicationException { + @Operation(summary = "Stream a ZIP for selected files", + description = "Accepts a text list of data file ids and streams the selected files as a ZIP archive.") + @SecurityRequirement(name = "DataverseApiKey") + public Response postDownloadDatafiles(@Context ContainerRequestContext crc, + @RequestBody(description = "Text list of data file ids to include in the ZIP archive.") + String body, + @Parameter(description = "Whether guestbook records have already been written for this download.") + @QueryParam("gbrecs") boolean gbrecs, + @Parameter(description = "Guestbook response id list supplied by the user interface.") + @QueryParam("gbrids") String gbrids, + @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) throws WebApplicationException { processDatafileWithGuestbookResponse(crc, headers, body, uriInfo, gbrecs, body); // JSF UI passes the guestbook response id(s) in thus this qp can be removed when JSF is removed @@ -861,7 +930,17 @@ public Response downloadAllFromLatest(@Context ContainerRequestContext crc, @Pat @AuthRequired @Path("dataset/{id}") @Produces({"application/zip"}) - public Response downloadAllFromLatestWithGuestbookResponse(@Context ContainerRequestContext crc, @PathParam("id") String datasetIdOrPersistentId, @QueryParam("gbrecs") boolean gbrecs, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response, String jsonBody) throws WebApplicationException { + @Operation(summary = "Submit guestbook response for latest dataset files", + description = "Records a guestbook response and prepares a ZIP download for files in the latest accessible dataset version.") + @SecurityRequirement(name = "DataverseApiKey") + public Response downloadAllFromLatestWithGuestbookResponse(@Context ContainerRequestContext crc, + @Parameter(description = "Dataset id or persistent identifier.", required = true) + @PathParam("id") String datasetIdOrPersistentId, + @Parameter(description = "Whether guestbook records have already been written for this download.") + @QueryParam("gbrecs") boolean gbrecs, + @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response, + @RequestBody(description = "Guestbook response JSON for the dataset file download.") + String jsonBody) throws WebApplicationException { try { User user = getRequestUser(crc); DataverseRequest req = createDataverseRequest(user); @@ -1290,7 +1369,9 @@ public InputStream fileCardImage(@PathParam("fileId") Long fileId, @Context UriI @Path("dsCardImage/{versionId}") @GET @Produces({ "image/png" }) - public InputStream dsCardImage(@PathParam("versionId") Long versionId, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ { + public InputStream dsCardImage(@Parameter(description = "Dataset version id used to locate the card image.", required = true) + @PathParam("versionId") Long versionId, + @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ { DatasetVersion datasetVersion = versionService.find(versionId); diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java index 1eecdd4b171..0acfb8a80b0 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java @@ -135,10 +135,15 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.StreamingOutput; +import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; import java.nio.file.Paths; import java.util.TreeMap; @@ -150,6 +155,7 @@ */ @Stateless @Path("admin") +@Tag(name = "Admin", description = "Administrative settings, users, roles, indexing, validation, and maintenance operations.") public class Admin extends AbstractApiBean { private static final Logger logger = Logger.getLogger(Admin.class.getName()); @@ -204,6 +210,8 @@ public class Admin extends AbstractApiBean { @Path("settings") @GET + @Operation(summary = "Enumerate database settings", + description = "Lists all Dataverse database settings as a JSON object.") @APIResponses({ @APIResponse(responseCode = "200", description = "All database options successfully queried", @@ -217,10 +225,13 @@ public Response listAllSettings() { @Path("settings") @PUT @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Replace database settings", + description = "Creates or replaces multiple Dataverse database settings from a JSON object.") @APIResponses({ @APIResponse(responseCode = "200", description = "All database options successfully updated") }) - public Response putAllSettings(JsonObject settings) { + public Response putAllSettings(@RequestBody(description = "JSON object whose keys are setting names and whose values are setting values.") + JsonObject settings) { try { // Basic JSON structure validation only if (settings == null || settings.isEmpty()) { @@ -237,7 +248,12 @@ public Response putAllSettings(JsonObject settings) { @Path("settings/{name}") @PUT - public Response putSetting(@PathParam("name") String name, String content) { + @Operation(summary = "Store a database setting", + description = "Creates or replaces a Dataverse database setting value.") + public Response putSetting(@Parameter(description = "Database setting name.", required = true) + @PathParam("name") String name, + @RequestBody(description = "Setting value to store.") + String content) { try { SettingsServiceBean.validateSettingName(name); @@ -250,7 +266,14 @@ public Response putSetting(@PathParam("name") String name, String content) { @Path("settings/{name}/lang/{lang}") @PUT - public Response putSettingLang(@PathParam("name") String name, @PathParam("lang") String lang, String content) { + @Operation(summary = "Store a localized database setting", + description = "Creates or replaces a language-specific Dataverse database setting value.") + public Response putSettingLang(@Parameter(description = "Database setting name.", required = true) + @PathParam("name") String name, + @Parameter(description = "Language code for the localized value.", required = true) + @PathParam("lang") String lang, + @RequestBody(description = "Localized setting value to store.") + String content) { try { SettingsServiceBean.validateSettingName(name); SettingsServiceBean.validateSettingLang(lang); @@ -264,7 +287,11 @@ public Response putSettingLang(@PathParam("name") String name, @PathParam("lang" @Path("settings/{name}") @GET - public Response getSetting(@PathParam("name") String name) { + @Operation(operationId = "Admin_getSettingByName", + summary = "Read a database setting", + description = "Returns the value for a Dataverse database setting.") + public Response getSetting(@Parameter(description = "Database setting name.", required = true) + @PathParam("name") String name) { try { SettingsServiceBean.validateSettingName(name); @@ -277,7 +304,13 @@ public Response getSetting(@PathParam("name") String name) { @Path("settings/{name}/lang/{lang}") @GET - public Response getSetting(@PathParam("name") String name, @PathParam("lang") String lang) { + @Operation(operationId = "Admin_getLocalizedSetting", + summary = "Read a localized database setting", + description = "Returns the language-specific value for a Dataverse database setting.") + public Response getSetting(@Parameter(description = "Database setting name.", required = true) + @PathParam("name") String name, + @Parameter(description = "Language code for the localized value.", required = true) + @PathParam("lang") String lang) { try { SettingsServiceBean.validateSettingName(name); SettingsServiceBean.validateSettingLang(lang); @@ -291,7 +324,10 @@ public Response getSetting(@PathParam("name") String name, @PathParam("lang") St @Path("settings/{name}") @DELETE - public Response deleteSetting(@PathParam("name") String name) { + @Operation(summary = "Remove a database setting", + description = "Deletes a Dataverse database setting by name.") + public Response deleteSetting(@Parameter(description = "Database setting name.", required = true) + @PathParam("name") String name) { try { SettingsServiceBean.validateSettingName(name); @@ -304,7 +340,12 @@ public Response deleteSetting(@PathParam("name") String name) { @Path("settings/{name}/lang/{lang}") @DELETE - public Response deleteSettingLang(@PathParam("name") String name, @PathParam("lang") String lang) { + @Operation(summary = "Remove a localized database setting", + description = "Deletes a language-specific Dataverse database setting value.") + public Response deleteSettingLang(@Parameter(description = "Database setting name.", required = true) + @PathParam("name") String name, + @Parameter(description = "Language code for the localized value.", required = true) + @PathParam("lang") String lang) { try { SettingsServiceBean.validateSettingName(name); SettingsServiceBean.validateSettingLang(lang); @@ -318,7 +359,10 @@ public Response deleteSettingLang(@PathParam("name") String name, @PathParam("la @Path("template/{id}") @DELETE - public Response deleteTemplate(@PathParam("id") long id) { + @Operation(summary = "Remove a metadata template", + description = "Deletes a metadata template and clears default-template references that point to it.") + public Response deleteTemplate(@Parameter(description = "Template database id.", required = true) + @PathParam("id") long id) { AuthenticatedUser superuser = authSvc.getAdminUser(); if (superuser == null) { @@ -346,13 +390,18 @@ public Response deleteTemplate(@PathParam("id") long id) { @Path("templates") @GET + @Operation(summary = "Enumerate metadata templates", + description = "Lists metadata templates with template id, name, and owner information.") public Response findAllTemplates() { return findTemplates(""); } @Path("templates/{alias}") @GET - public Response findTemplates(@PathParam("alias") String alias) { + @Operation(summary = "Enumerate metadata templates for a dataverse", + description = "Lists metadata templates owned by the dataverse with the supplied alias.") + public Response findTemplates(@Parameter(description = "Dataverse alias whose templates are listed.", required = true) + @PathParam("alias") String alias) { List