diff --git a/conf/keycloak/docker-compose-dev.yml b/conf/keycloak/docker-compose-dev.yml index 7356161ec47..7e57cd7d83c 100644 --- a/conf/keycloak/docker-compose-dev.yml +++ b/conf/keycloak/docker-compose-dev.yml @@ -53,16 +53,16 @@ services: -Ddataverse.files.localstack1.download-redirect=true -Ddataverse.files.localstack1.access-key=default -Ddataverse.files.localstack1.secret-key=default - -Ddataverse.files.minio1.type=s3 - -Ddataverse.files.minio1.label=MinIO - -Ddataverse.files.minio1.custom-endpoint-url=http://minio:9000 - -Ddataverse.files.minio1.custom-endpoint-region=us-east-1 - -Ddataverse.files.minio1.bucket-name=mybucket - -Ddataverse.files.minio1.path-style-access=true - -Ddataverse.files.minio1.upload-redirect=false - -Ddataverse.files.minio1.download-redirect=false - -Ddataverse.files.minio1.access-key=4cc355_k3y - -Ddataverse.files.minio1.secret-key=s3cr3t_4cc355_k3y + -Ddataverse.files.localstack_noredirect.type=s3 + -Ddataverse.files.localstack_noredirect.label=LocalStackNoRedirect + -Ddataverse.files.localstack_noredirect.custom-endpoint-url=http://localstack:4566 + -Ddataverse.files.localstack_noredirect.custom-endpoint-region=us-east-2 + -Ddataverse.files.localstack_noredirect.bucket-name=mybucket-noredirect + -Ddataverse.files.localstack_noredirect.path-style-access=true + -Ddataverse.files.localstack_noredirect.upload-redirect=false + -Ddataverse.files.localstack_noredirect.download-redirect=false + -Ddataverse.files.localstack_noredirect.access-key=default + -Ddataverse.files.localstack_noredirect.secret-key=default -Ddataverse.pid.providers=fake -Ddataverse.pid.default-provider=fake -Ddataverse.pid.fake.type=FAKE @@ -260,23 +260,6 @@ services: tmpfs: - /localstack:mode=770,size=128M,uid=1000,gid=1000 - dev_minio: - container_name: "dev_minio" - hostname: "minio" - image: minio/minio - restart: on-failure - ports: - - "9000:9000" - - "9001:9001" - networks: - - dataverse - volumes: - - ./docker-dev-volumes/minio_storage:/data - environment: - MINIO_ROOT_USER: 4cc355_k3y - MINIO_ROOT_PASSWORD: s3cr3t_4cc355_k3y - command: server /data - previewers-provider: container_name: previewers-provider hostname: previewers-provider diff --git a/conf/localstack/buckets.sh b/conf/localstack/buckets.sh index fe940d9890d..bd901c19634 100755 --- a/conf/localstack/buckets.sh +++ b/conf/localstack/buckets.sh @@ -1,3 +1,4 @@ #!/usr/bin/env bash # https://stackoverflow.com/questions/53619901/auto-create-s3-buckets-on-localstack awslocal s3 mb s3://mybucket +awslocal s3 mb s3://mybucket-noredirect diff --git a/doc/release-notes/11397-support-html-in-file-level-description.md b/doc/release-notes/11397-support-html-in-file-level-description.md new file mode 100644 index 00000000000..c0d6b597ce3 --- /dev/null +++ b/doc/release-notes/11397-support-html-in-file-level-description.md @@ -0,0 +1,2 @@ +## Feature Request +File metadata description will now support html in JSF UI. Is already supported in SPA. diff --git a/doc/release-notes/12076-non-superuser-dataverse-linking.md b/doc/release-notes/12076-non-superuser-dataverse-linking.md new file mode 100644 index 00000000000..6ed55b65031 --- /dev/null +++ b/doc/release-notes/12076-non-superuser-dataverse-linking.md @@ -0,0 +1 @@ +Dataverse collection linking and unlinking no longer requires superuser status. Users with the "Link Dataverse" permission on a collection can now perform these actions through the UI and API. diff --git a/doc/release-notes/12200-async-reindexing-permissions.md b/doc/release-notes/12200-async-reindexing-permissions.md new file mode 100644 index 00000000000..4ca976b3331 --- /dev/null +++ b/doc/release-notes/12200-async-reindexing-permissions.md @@ -0,0 +1,9 @@ +# Asynchronous Permissions Reindexing + +The previously undocumented Solr permissions reindexing API endpoints have been improved. + +- The endpoints are `/api/admin/index/perms` (asynchronous, all objects) and `/api/admin/index/perms/{id}` (synchronous, single object) now use POST instead of GET +- Both endpoints require superuser access. +- For the asynchronous reindex all endpoint, if an indexing process is already in progress, the API will return a 409 Conflict status. +- The asynchronous reindex all endpoint no longer runs as a single transaction. This avoids potential timeouts in larger installations. +- These endpoints are now documented in the Solr Search Index section of the Admin Guide. diff --git a/doc/release-notes/12412-fixed-license-acceptance-popup-for-archival-format.md b/doc/release-notes/12412-fixed-license-acceptance-popup-for-archival-format.md new file mode 100644 index 00000000000..71a3cba4d59 --- /dev/null +++ b/doc/release-notes/12412-fixed-license-acceptance-popup-for-archival-format.md @@ -0,0 +1 @@ +Fixed an issue where, in some cases, the license acceptance pop-up for the archival format option did not display correctly when downloading a complete dataset. \ No newline at end of file diff --git a/doc/release-notes/12439-openapi.md b/doc/release-notes/12439-openapi.md new file mode 100644 index 00000000000..ab921de2483 --- /dev/null +++ b/doc/release-notes/12439-openapi.md @@ -0,0 +1,7 @@ +## Updates for API Users + +- The quality of the OpenAPI document has been greatly improved, increasing from 10/100 to 98/100 according [vacuum](https://quobix.com/vacuum/). See [the guides](https://dataverse-guide--12439.org.readthedocs.build/en/12439/api/getting-started.html#getting-the-openapi-document) and #12439. + +## Updates for Developers + +- If you are adding or updating Dataverse APIs you are now required to add OpenAPI annotations. See [the guides](https://dataverse-guide--12439.org.readthedocs.build/en/12439/developers/api-design.html#openapi) and #12439. diff --git a/doc/sphinx-guides/source/admin/dataverses-datasets.rst b/doc/sphinx-guides/source/admin/dataverses-datasets.rst index c5820422953..dc4dfabfedc 100644 --- a/doc/sphinx-guides/source/admin/dataverses-datasets.rst +++ b/doc/sphinx-guides/source/admin/dataverses-datasets.rst @@ -22,14 +22,14 @@ Moves a Dataverse collection whose id is passed to an existing Dataverse collect Link a Dataverse Collection ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Creates a link between a Dataverse collection and another Dataverse collection (see the :ref:`dataverse-linking` section of the User Guide for more information). Only accessible to superusers. :: +Creates a link between a Dataverse collection and another Dataverse collection (see the :ref:`dataverse-linking` section of the User Guide for more information). :: curl -H "X-Dataverse-key: $API_TOKEN" -X PUT http://$SERVER/api/dataverses/$linked-dataverse-alias/link/$linking-dataverse-alias Unlink a Dataverse Collection ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Removes a link between a Dataverse collection and another Dataverse collection. Only accessible to superusers. :: +Removes a link between a Dataverse collection and another Dataverse collection. Accessible to users with Link Dataverse permission on the linking Dataverse collection. :: curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE http://$SERVER/api/dataverses/$linked-dataverse-alias/deleteLink/$linking-dataverse-alias diff --git a/doc/sphinx-guides/source/admin/solr-search-index.rst b/doc/sphinx-guides/source/admin/solr-search-index.rst index 3f7b9d5b547..27eb7fab6f4 100644 --- a/doc/sphinx-guides/source/admin/solr-search-index.rst +++ b/doc/sphinx-guides/source/admin/solr-search-index.rst @@ -96,6 +96,37 @@ This API will clear the Solr entry for the dataset specified. It can be useful i This can be reversed of course by re-indexing the dataset with the API above. +Reindexing Permissions +---------------------- + +It is possible to just re-index the permissions on Solr entries without re-indexing the content. + +Reindexing Permissions for All Objects +++++++++++++++++++++++++++++++++++++++ + +Re-index permissions for all Dataverse collections, datasets, and files. This is an asynchronous operation that may take a long time to complete on large installations. This endpoint requires the API token of a superuser. + +.. code-block:: bash + + export SERVER_URL=https://demo.dataverse.org + curl -X POST -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/admin/index/perms" + +If indexing is already in progress, the API will return a 409 ("CONFLICT") response. Otherwise, it will return a 200 ("OK") response and the indexing process will start in the background. Check the server logs for progress. + +Reindexing Permissions for a Single Object +++++++++++++++++++++++++++++++++++++++++++ + +It is also possible to re-index permissions for a single object by database ID: + +.. code-block:: bash + + export SERVER_URL=https://demo.dataverse.org + export ID=42 + curl -X POST -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/admin/index/perms/$ID" + +This operation is performed synchronously. + + Manually Querying Solr ---------------------- diff --git a/doc/sphinx-guides/source/api/changelog.rst b/doc/sphinx-guides/source/api/changelog.rst index fb346e96821..10e368da51f 100644 --- a/doc/sphinx-guides/source/api/changelog.rst +++ b/doc/sphinx-guides/source/api/changelog.rst @@ -7,9 +7,16 @@ This API changelog is experimental and we would love feedback on its usefulness. :local: :depth: 1 -v6.11 +v6.12 ----- +- The permission reindexing endpoints have been updated to use ``POST`` and require superuser access. They are now documented in the :doc:`/admin/solr-search-index` guide. + + - **/api/admin/index/perms** + + - **/api/admin/index/perms/{id}** +v6.11 +----- - The GET /api/mydata/retrieve, if the search returns no data, now includes the "data" block with 0 results. The message that was returned in "error_message" will be returned in "message" and the "success" will be `true`. All other errors will continue to reply with "success":false and the error message in "error_message". - The endpoints GET, PUT AND DELETE for `/api/admin/dataverse/{alias}/storageDriver` have been moved to `/api/dataverses/{alias}/storageDriver`. - The endpoint `/api/admin/dataverse/storageDrivers` has been moved and renamed to `/api/dataverses/{alias}/allowedStorageDrivers`. Regarding the change of the name, this endpoint will in the future only display the storageDrivers that are allowed on the specified collection, as of now, it will display the entire list of available Drivers on the installation. diff --git a/doc/sphinx-guides/source/api/getting-started.rst b/doc/sphinx-guides/source/api/getting-started.rst index 3f93f4ac444..e29bafd8c2a 100644 --- a/doc/sphinx-guides/source/api/getting-started.rst +++ b/doc/sphinx-guides/source/api/getting-started.rst @@ -178,7 +178,9 @@ The fully expanded example above (without environment variables) looks like this curl "https://demo.dataverse.org/openapi?format=json" -We are aware that our OpenAPI document is not perfect. You can find more information about validating the document under :ref:`openapi-dev` in the Developer Guide. +If you have feedback about the our OpenAPI document, please contact us through the usual channels under :ref:`getting-help-with-apis`. + +You can find more information about validating the document under :ref:`openapi-dev` in the Developer Guide. Beyond "Getting Started" Tasks ------------------------------ diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index eaff1c77057..582891e0861 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -3746,6 +3746,66 @@ The fully expanded example above (without environment variables) looks like this curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X PUT "https://demo.dataverse.org/api/datasets/24/link/test" +Unlink a Dataset +~~~~~~~~~~~~~~~~ + +Removes a link between a dataset and a Dataverse collection (see :ref:`dataset-linking` section of Dataverse Collection Management in the User Guide for more information): + +.. code-block:: bash + + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export DATASET_ID=24 + export DATAVERSE_ID=test + + curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE "$SERVER_URL/api/datasets/$DATASET_ID/deleteLink/$DATAVERSE_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" -X DELETE "https://demo.dataverse.org/api/datasets/24/deleteLink/test" + +Link a Dataverse collection +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Creates a link between one Dataverse collection and another Dataverse collection (see :ref:`dataverse-linking` section of Dataverse Collection Management in the User Guide for more information): + +.. code-block:: bash + + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export LINKED_DATAVERSE_ID=linked-collection + export LINKING_DATAVERSE_ID=linking-collection + + curl -H "X-Dataverse-key: $API_TOKEN" -X PUT "$SERVER_URL/api/dataverses/$LINKED_DATAVERSE_ID/link/$LINKING_DATAVERSE_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" -X PUT "https://demo.dataverse.org/api/dataverses/linked-collection/link/linking-collection" + +Unlink a Dataverse collection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Removes a link between one Dataverse collection and another Dataverse collection (see :ref:`dataverse-linking` section of Dataverse Collection Management in the User Guide for more information): + +.. code-block:: bash + + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export LINKED_DATAVERSE_ID=linked-collection + export LINKING_DATAVERSE_ID=linking-collection + + curl -H "X-Dataverse-key: $API_TOKEN" -X DELETE "$SERVER_URL/api/dataverses/$LINKED_DATAVERSE_ID/deleteLink/$LINKING_DATAVERSE_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" -X DELETE "https://demo.dataverse.org/api/dataverses/linked-collection/deleteLink/linking-collection" + Dataset Locks ~~~~~~~~~~~~~ diff --git a/doc/sphinx-guides/source/developers/api-design.rst b/doc/sphinx-guides/source/developers/api-design.rst index d51481fece4..04a8d632ef3 100755 --- a/doc/sphinx-guides/source/developers/api-design.rst +++ b/doc/sphinx-guides/source/developers/api-design.rst @@ -14,13 +14,39 @@ OpenAPI As you add API endpoints, please be conscious that we are exposing these endpoints as an OpenAPI document at ``/openapi`` (e.g. http://localhost:8080/openapi ). See :ref:`openapi` in the API Guide for the user-facing documentation. -We've played around with validation tools such as https://quobix.com/vacuum/ and https://pb33f.io/doctor/ only to discover that our OpenAPI output is less than ideal, generating various warnings and errors. +OpenAPI Annotations +~~~~~~~~~~~~~~~~~~~ + +To keep our OpenAPI document at a reasonable quality, you must add annotations to API endpoints you are adding and editing. Here's an example: + +.. code-block:: java + + @POST + @AuthRequired + @Path("/") + @Operation(summary = "Creates a license", description = "Creates a license when the authenticated user is a superuser and returns the created license location.") + @APIResponse(responseCode = "201", description = "License created with a Location header pointing to the new license.") + public Response addLicense(@Context ContainerRequestContext crc, + @RequestBody(description = "License definition to persist, including name, URI, active state, and sort order.", + content = @Content(mediaType = "application/json", + schema = @Schema(type = SchemaType.OBJECT, + description = "License definition accepted by the license administration API."))) + License license) { + ... + } + +If you are looking for a reference about the annotations used to generate the OpenAPI document, you can find it in the `MicroProfile OpenAPI Specification `_. + +OpenAPI Quality +~~~~~~~~~~~~~~~ + +To use `vacuum `_ with our recommended settings to check the quality of our OpenAPI document, see the :download:`README.md <../../../../scripts/openapi/README.md>` under ``scripts/openapi``. You can prevent additional problems in our OpenAPI document by observing the following practices: - When creating a method name within an API class, make it unique. - -If you are looking for a reference about the annotations used to generate the OpenAPI document, you can find it in the `MicroProfile OpenAPI Specification `_. +- Check that HTTP GET and DELETE operations do not accept request bodies. +- Various notes under :download:`vacuum-recommended.yaml <../../../../scripts/openapi/vacuum-recommended.yaml>`. Paths ----- diff --git a/doc/sphinx-guides/source/developers/making-releases.rst b/doc/sphinx-guides/source/developers/making-releases.rst index 2e5b3f46b5f..a61a171cdf3 100755 --- a/doc/sphinx-guides/source/developers/making-releases.rst +++ b/doc/sphinx-guides/source/developers/making-releases.rst @@ -224,7 +224,14 @@ Merge "develop" into "master" (non-hotfix only) If this is a regular (non-hotfix) release, create a pull request to merge the "develop" branch into the "master" branch using this "compare" link: https://github.com/IQSS/dataverse/compare/master...develop -Allow time for important tests (compile, unit tests, etc.) to pass. Don't worry about style tests failing such as for shell scripts. It's ok to skip code review. +Allow time for important tests pass: + +- Unit tests: Maven Tests +- API tests: Container Integration Tests Workflow + +Don't worry about style tests failing such as for shell scripts. + +It's ok to skip code review. When merging the pull request, be sure to choose "create a merge commit" and not "squash and merge" or "rebase and merge". We suspect that choosing squash or rebase may have led to `lots of merge conflicts `_ when we tried to perform this "merge develop to master" step, forcing us to `re-do `_ the previous release before we could proceed with the current release. @@ -244,17 +251,6 @@ Often someone is making sure that the proper milestone (e.g. 6.10.1) is being ap Check for merged pull requests that have no milestone by going to https://github.com/IQSS/dataverse/pulls and entering `is:pr is:merged no:milestone `_ as a query. If you find any, first check if those pull requests are against open pull requests. If so, do nothing. Otherwise, add the milestone to the pull request and any issues it closes. This includes the "merge develop into master" pull request above. -(Optional) Test Docker Images ------------------------------ - -After the "master" branch has been updated and the GitHub Action to build and push Docker images has run (see `PR #9776 `_), go to https://hub.docker.com/u/gdcc and make sure the "latest" tag for the following images has been updated: - -- https://hub.docker.com/r/gdcc/base -- https://hub.docker.com/r/gdcc/dataverse -- https://hub.docker.com/r/gdcc/configbaker - -TODO: Get https://github.com/gdcc/api-test-runner working. - .. _build-guides: Build the Guides for the Release @@ -277,7 +273,7 @@ As described below, we'll soon point the "latest" symlink to that new directory. Run a Build to Create the War File ---------------------------------- -Go to https://github.com/IQSS/dataverse/actions/workflows/generate_war_file.yml click "run workflow". For a regular release, change the branch to "master". For a hotfix release, use whatever branch name is used for the hotfix. Leave the custom label blank and click "run workflow". This will create an action that should result in a zip file. Inside that zip is another zip that contains the war file. +Go to https://github.com/IQSS/dataverse/actions/workflows/generate_war_file.yml click "run workflow". For a regular release, change the branch to "master". For a hotfix release, use whatever branch name is used for the hotfix. Leave the custom label blank and click "run workflow". This will create an action that should result in a zip file. Inside that zip is another zip that contains the war file. Download it. The build number will appear in ``/api/info/version`` (along with the commit mentioned above) from a running installation (e.g. ``{"version":"6.10.1","build":"master-300d5b5"}``). @@ -286,12 +282,17 @@ Build Installer (dvinstall.zip) In a git checkout of the source, switch to the master branch and pull the latest. -Copy the war file from the previous step (shown in ``/tmp`` in the example below ) to the ``target`` directory in the root of the repo (create the ``target`` directory, if necessary): +Unzip the zip file from the previous step. + +Copy the war file to the ``target`` directory in the root of the repo (create the ``target`` directory, if necessary): .. code-block:: bash - mkdir target - cp /tmp/dataverse-6.10.1.war target + cp ~/Downloads/built-app.zip . + unzip built-app.zip + rm built-app.zip + mkdir -p target + mv dataverse-*.war target Then, create the installer: @@ -338,6 +339,27 @@ ssh into the guides server and update the symlink to point to the latest release This step could be done before publishing the release if you'd like to double check that links in the release notes work. +Test Docker Images +------------------ + +Publishing the release should have trigged the "Container Images Scheduled Maintenance" GitHub Action. Allow it to finish and then go to https://hub.docker.com/u/gdcc and navigate to "gdcc/dataverse". + +Click on "tags" and look at the "latest" tag. Was it just updated? Good! If not, we plan to address this is https://github.com/IQSS/dataverse/issues/12514 but for now, as a workaround, run the action again. Go to https://github.com/IQSS/dataverse/actions/workflows/container_maintenance.yml and click the "run workflow" dropdown. Make sure the branch is set to "develop" and click "run workflow" button. + +Wait for the action to finish and then check again that the "latest" tag has been updated. + +Locally, delete old images and spin up the "latest" tag. + +.. code-block:: bash + + docker rmi gdcc/dataverse:latest + docker rmi gdcc/configbaker:latest + cd docker/compose/demo + rm -rf data + docker compose up + +Wait for the bootstrapping process to complete. Then, look at http://localhost:8080/api/info/version to make sure "version" shows the version that you just released. Note that it's normal for "build" to be null for our Docker images. + Close Milestone on GitHub and Create a New One ---------------------------------------------- @@ -360,7 +382,17 @@ Create a new branch (any name is fine but ``prepare-next-iteration`` is suggeste - modules/dataverse-parent/pom.xml -> ```` -> profile "ct" -> ```` -> Set ```` to ``${parsedVersion.majorVersion}.${parsedVersion.nextMinorVersion}`` -Create a pull request and put it through code review, like usual. Give it a milestone of the next release, the one **after** the one we're working on. Once the pull request has been approved, merge it. It should be the first PR merged of the next release. +Create a pull request. + +Wait for checks to complete. It's ok (even expected) for the following check to fail: + +- main-integration-tests-workflow from container_integration_tests.yml + + - If you see an error like ``Error: DOCKER> Unable to pull 'gdcc/base:6.12-noble-p7.2026.2-j21' : {"message":"manifest for gdcc/base:6.12-noble-p7.2026.2-j21 not found: manifest unknown: manifest unknown"} (Not Found: 404) [{"message":"manifest for gdcc/base:6.12-noble-p7.2026.2-j21 not found: manifest unknown: manifest unknown"} (Not Found: 404)]`` it's telling you that the Docker image can't be spun up for API testing because it doesn't exist yet. (The error above was just after the 6.11 release.) The image will exist once the pull request is approved and merged. + +Put the pull request through code review, like usual, but make sure reviewers know it's ok to ignore the check above. + +Give it a milestone of the next release, the one **after** the one we're working on. Once the pull request has been approved, merge it. It should be the first PR merged of the next release. For more background, see :ref:`base-image-supported-tags`. For an example, see https://github.com/IQSS/dataverse/pull/10896 diff --git a/doc/sphinx-guides/source/installation/big-data-support.rst b/doc/sphinx-guides/source/installation/big-data-support.rst index 45b94f71a9f..411bd6b54d8 100644 --- a/doc/sphinx-guides/source/installation/big-data-support.rst +++ b/doc/sphinx-guides/source/installation/big-data-support.rst @@ -68,7 +68,6 @@ If the bucket allows the wildcard ``*`` but the Dataverse application only allow Detailed information for the most common S3 admin tools around CORS: - `AWS `_ -- `Minio mc `_ - `s3cmd `_ Get Current CORS Policy on Bucket @@ -80,9 +79,6 @@ If you'd like to check the CORS configuration on your bucket before making chang .. group-tab:: AWS CLI :code:`aws s3api get-bucket-cors --bucket ` - .. group-tab:: Minio Client (mc) - :code:`mc cors get /` - Set CORS Policy on Bucket +++++++++++++++++++++++++ @@ -107,9 +103,6 @@ Both JSON and XML format are explained in detail in `AWS Docs ` as follows: - .. literalinclude:: /_static/installation/cors/cors.xml :name: xml-cors :language: xml @@ -124,7 +117,7 @@ Both JSON and XML format are explained in detail in `AWS Docs `_ - Set ``dataverse.files..path-style-access=true``, as Minio works path-based. Works pretty smooth, easy to setup. - **Can be used for quick testing, too:** just use the example values above. Uses the public (read: unsecure and - possibly slow) https://play.minio.io:9000 service. - `StorJ Object Store `_ StorJ is a distributed object store that can be configured with an S3 gateway. Per the S3 Storage instructions above, you'll first set up the StorJ S3 store by defining the id, type, and label. After following the general installation, set the following configuration to use a StorJ object store: ``dataverse.files..chunked-encoding=false``. For step-by-step instructions see https://docs.storj.io/dcs/how-tos/dataverse-integration-guide/ diff --git a/doc/sphinx-guides/source/user/dataset-management.rst b/doc/sphinx-guides/source/user/dataset-management.rst index 17eff033882..d56c574a088 100755 --- a/doc/sphinx-guides/source/user/dataset-management.rst +++ b/doc/sphinx-guides/source/user/dataset-management.rst @@ -726,7 +726,7 @@ Preview URL to Review Unpublished Dataset Creating a Preview URL for a draft version of your dataset allows you to share your dataset (for viewing and downloading files, including :ref:`restricted ` and :ref:`embargoed ` files) before it is published to a wide group of people who might not have a user account on the Dataverse installation. Anyone you send the Preview URL to will not have to log in to the Dataverse installation to view the unpublished dataset. Once a dataset has been published, you may create new General Preview URLs for subsequent draft versions, but the Anonymous Preview URL will no longer be available. -**Note:** To create a Preview URL, you must have the *ManageDatasetPermissions* permission for your draft dataset, usually given by the :ref:`roles ` *Curator* or *Administrator*. +**Note:** To create a Preview URL, you must have the *ManageDatasetPermissions* permission for your draft dataset, usually given by the :ref:`roles ` *Curator* or *Administrator*. Take into account that before being able to create an Anonymous Preview URL, your system administrator much configure :ref:`a setting <:AnonymizedFieldTypeNames>` that indicates which fields will be withheld (Author Name, for example) when dataset access occurs via a Preview Url with Anonymized Access. #. Go to your unpublished dataset #. Select the “Edit” button diff --git a/doc/sphinx-guides/source/user/dataverse-management.rst b/doc/sphinx-guides/source/user/dataverse-management.rst index 4799d2cdb30..7cded79f9bf 100755 --- a/doc/sphinx-guides/source/user/dataverse-management.rst +++ b/doc/sphinx-guides/source/user/dataverse-management.rst @@ -221,18 +221,18 @@ In order to link a dataset, you will need your account to have the "Link Dataset To link a dataset to your Dataverse collection, you must navigate to that dataset and click the white "Link" button in the upper-right corner of the dataset page. This will open up a window where you can type in the name of the Dataverse collection that you would like to link the dataset to. Select your Dataverse collection and click the save button. This will establish the link, and the dataset will now appear under your Dataverse collection. -A draft dataset can be linked to other Dataverse collections. It will only become publicly visible in the linked collection(s) after it has been published. To publish the dataset, your account must have the "Publish Dataset" permission for the Dataverse collection in which the dataset was originally created. Permissions in the linked Dataverse collections do not apply. +To remove an established link, navigate to the linked dataset's page and click the white "Unlink" button in the upper-right corner of the page. -There is currently no way to remove established links in the UI. If you need to remove a link between a Dataverse collection and a dataset, please contact the support team for the Dataverse installation you are using (see the :ref:`unlink-a-dataset` section of the Admin Guide for more information). +A draft dataset can be linked to other Dataverse collections. It will only become publicly visible in the linked collection(s) after it has been published. To publish the dataset, your account must have the "Publish Dataset" permission for the Dataverse collection in which the dataset was originally created. Permissions in the linked Dataverse collections do not apply. .. _dataverse-linking: Dataverse Collection Linking ============================ -Similarly to dataset linking, Dataverse collection linking allows a Dataverse collection owner to "link" their Dataverse collection to another Dataverse collection, so the Dataverse collection being linked will appear in the linking Dataverse collection's list of contents without actually *being* in that Dataverse collection. Currently, the ability to link a Dataverse collection to another Dataverse collection is a superuser only feature. +Similarly to dataset linking, Dataverse collection linking allows a Dataverse collection owner to "link" their Dataverse collection to another Dataverse collection, so the Dataverse collection being linked will appear in the linking Dataverse collection's list of contents without actually *being* in that Dataverse collection. -If you need to have a Dataverse collection linked to your Dataverse collection, please contact the support team for the Dataverse installation you are using. +In order to link a collection, you will need your account to have the "Link Dataverse" permission on the linking Dataverse collection. Publish Your Dataverse Collection ================================= diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index b24bf0ed6f6..c176597c990 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -44,16 +44,16 @@ services: -Ddataverse.files.localstack1.download-redirect=true -Ddataverse.files.localstack1.access-key=default -Ddataverse.files.localstack1.secret-key=default - -Ddataverse.files.minio1.type=s3 - -Ddataverse.files.minio1.label=MinIO - -Ddataverse.files.minio1.custom-endpoint-url=http://minio:9000 - -Ddataverse.files.minio1.custom-endpoint-region=us-east-1 - -Ddataverse.files.minio1.bucket-name=mybucket - -Ddataverse.files.minio1.path-style-access=true - -Ddataverse.files.minio1.upload-redirect=false - -Ddataverse.files.minio1.download-redirect=false - -Ddataverse.files.minio1.access-key=4cc355_k3y - -Ddataverse.files.minio1.secret-key=s3cr3t_4cc355_k3y + -Ddataverse.files.localstack_noredirect.type=s3 + -Ddataverse.files.localstack_noredirect.label=LocalStackNoRedirect + -Ddataverse.files.localstack_noredirect.custom-endpoint-url=http://localstack:4566 + -Ddataverse.files.localstack_noredirect.custom-endpoint-region=us-east-2 + -Ddataverse.files.localstack_noredirect.bucket-name=mybucket-noredirect + -Ddataverse.files.localstack_noredirect.path-style-access=true + -Ddataverse.files.localstack_noredirect.upload-redirect=false + -Ddataverse.files.localstack_noredirect.download-redirect=false + -Ddataverse.files.localstack_noredirect.access-key=default + -Ddataverse.files.localstack_noredirect.secret-key=default -Ddataverse.pid.providers=fake -Ddataverse.pid.default-provider=fake -Ddataverse.pid.fake.type=FAKE @@ -252,23 +252,6 @@ services: tmpfs: - /localstack:mode=770,size=128M,uid=1000,gid=1000 - dev_minio: - container_name: "dev_minio" - hostname: "minio" - image: minio/minio - restart: on-failure - ports: - - "9000:9000" - - "9001:9001" - networks: - - dataverse - volumes: - - ./docker-dev-volumes/minio_storage:/data - environment: - MINIO_ROOT_USER: 4cc355_k3y - MINIO_ROOT_PASSWORD: s3cr3t_4cc355_k3y - command: server /data - previewers-provider: container_name: previewers-provider hostname: previewers-provider diff --git a/pom.xml b/pom.xml index dd4d92c4f0d..50c44586ca3 100644 --- a/pom.xml +++ b/pom.xml @@ -1128,6 +1128,7 @@ CLASS_METHOD edu.harvard.iq.dataverse true + edu.harvard.iq.dataverse.openapi.DataverseOpenApiFilter diff --git a/scripts/dev/dev-start-frd.sh b/scripts/dev/dev-start-frd.sh index d113f677bad..d76bed85770 100755 --- a/scripts/dev/dev-start-frd.sh +++ b/scripts/dev/dev-start-frd.sh @@ -28,7 +28,6 @@ mkdir -p docker-dev-volumes/app/secrets mkdir -p docker-dev-volumes/postgresql/data mkdir -p docker-dev-volumes/solr/data mkdir -p docker-dev-volumes/solr/conf -mkdir -p docker-dev-volumes/minio_storage # Only disable DDL generation if database is already initialized # (on first run, we need create-tables to bootstrap the schema) diff --git a/scripts/openapi/README.md b/scripts/openapi/README.md new file mode 100644 index 00000000000..6456bbf50c6 --- /dev/null +++ b/scripts/openapi/README.md @@ -0,0 +1,71 @@ +# OpenAPI Quality Checks + +This directory contains the Dataverse vacuum ruleset used to check the generated +OpenAPI document while preserving known legacy API shapes. + +## vacuum + +vacuum is a command-line linter and quality checker for OpenAPI, AsyncAPI, and +JSON Schema documents. It is compatible with Spectral rulesets, so the Dataverse +ruleset can extend the built-in `vacuum:oas` recommended profile and disable +rules that are noisy for the current API surface. + +Project and documentation: + +- GitHub: https://github.com/daveshanley/vacuum +- Docs: https://quobix.com/vacuum + +Install with Homebrew: + +```bash +brew install --cask daveshanley/vacuum/vacuum +``` + +Install with npm: + +```bash +npm i -g @quobix/vacuum +``` + +Install with curl: + +```bash +curl -fsSL https://quobix.com/scripts/install_vacuum.sh | sh +``` + +## Checking Dataverse OpenAPI + +Generate the OpenAPI document from the repository root: + +```bash +mvn -q -DskipTests process-classes +``` + +The generated JSON is written to: + +```text +target/classes/META-INF/openapi.json +``` + +Run the vacuum checks from the repository root: + +```bash +vacuum lint -r scripts/openapi/vacuum-recommended.yaml target/classes/META-INF/openapi.json +``` + +For a fuller report: + +```bash +vacuum report -r scripts/openapi/vacuum-recommended.yaml target/classes/META-INF/openapi.json +``` + +For the interactive dashboard: + +```bash +vacuum dashboard -r scripts/openapi/vacuum-recommended.yaml target/classes/META-INF/openapi.json +``` + +The ruleset extends `vacuum:oas` recommended rules, with legacy/noisy checks +disabled where Dataverse intentionally keeps historical endpoint names or lacks +examples. Request-body findings remain enabled so unused body parameters on GET +endpoints are reported and can be removed from source. diff --git a/scripts/openapi/vacuum-recommended.yaml b/scripts/openapi/vacuum-recommended.yaml new file mode 100644 index 00000000000..32710eb31a8 --- /dev/null +++ b/scripts/openapi/vacuum-recommended.yaml @@ -0,0 +1,19 @@ +description: Dataverse OpenAPI recommended rules with legacy/noisy rules disabled. +extends: [[vacuum:oas, recommended]] +rules: + # Endpoint paths are historically camel case, and we won't change those for OpenAPI conformance + paths-kebab-case: false + # We do have duplications, because the same parameters as `id` will be used in multiple endpoints + description-duplication: false + # We don't have examples + oas3-missing-example: false + # We have /pids/{id}/delete --> see edu.harvard.iq.dataverse.api.Pids#deletePid + # The HTTP verb there is DELETE so actually we don't need to have the verb in the path, + # could be fixed. + no-http-verbs-in-path: false + # These endpoints have an unused `body` parameter, probably because of copy-paste. + # no-request-body: false would not report them, but rather we should fix them to remove `body` + # GET /files/{id}/prov-freeform + # GET /files/{id}/prov-json + # GET /files/{id}/prov-json + #no-request-body: false diff --git a/src/main/docker/Dockerfile b/src/main/docker/Dockerfile index e6dbb2f8679..d30ed83d533 100644 --- a/src/main/docker/Dockerfile +++ b/src/main/docker/Dockerfile @@ -30,7 +30,7 @@ FROM $BASE_IMAGE ENV MP_CONFIG_PROFILE=ct # Workaround to configure upload directories by default to useful place until we can have variable lookups in -# defaults for glassfish-web.xml and other places. +# defaults for payara-web.xml and other places. ENV DATAVERSE_FILES_UPLOADS="${STORAGE_DIR}/uploads" ENV DATAVERSE_FILES_DOCROOT="${STORAGE_DIR}/docroot" diff --git a/src/main/java/edu/harvard/iq/dataverse/AlternativePersistentIdentifier.java b/src/main/java/edu/harvard/iq/dataverse/AlternativePersistentIdentifier.java index 246cd681323..18fbdccd443 100644 --- a/src/main/java/edu/harvard/iq/dataverse/AlternativePersistentIdentifier.java +++ b/src/main/java/edu/harvard/iq/dataverse/AlternativePersistentIdentifier.java @@ -5,6 +5,7 @@ import java.util.Date; import jakarta.persistence.*; +import org.eclipse.microprofile.openapi.annotations.media.Schema; /** * @@ -16,6 +17,7 @@ } ) @Entity +@Schema(description = "Alternate persistent identifier assigned to a Dataverse object, including protocol, authority, identifier, and registration state.") public class AlternativePersistentIdentifier implements Serializable { @Id diff --git a/src/main/java/edu/harvard/iq/dataverse/AuxiliaryFile.java b/src/main/java/edu/harvard/iq/dataverse/AuxiliaryFile.java index d03ebbc6f7b..4d71a512219 100644 --- a/src/main/java/edu/harvard/iq/dataverse/AuxiliaryFile.java +++ b/src/main/java/edu/harvard/iq/dataverse/AuxiliaryFile.java @@ -14,6 +14,7 @@ import jakarta.persistence.NamedNativeQuery; import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQuery; +import org.eclipse.microprofile.openapi.annotations.media.Schema; /** * @@ -38,6 +39,7 @@ query = "select distinct type from auxiliaryfile where datafile_id = ?1") }) @Entity +@Schema(description = "Metadata for an auxiliary file associated with a data file, including format, origin, visibility, size, checksum, and type.") public class AuxiliaryFile implements Serializable { @Id diff --git a/src/main/java/edu/harvard/iq/dataverse/BannerMessage.java b/src/main/java/edu/harvard/iq/dataverse/BannerMessage.java index 003d1057972..a32307f084c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/BannerMessage.java +++ b/src/main/java/edu/harvard/iq/dataverse/BannerMessage.java @@ -11,6 +11,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import org.eclipse.microprofile.openapi.annotations.media.Schema; /** @@ -18,6 +19,7 @@ * @author skraffmi */ @Entity +@Schema(description = "Site banner message configuration, including active state, dismissibility, and localized message text.") public class BannerMessage implements Serializable { @Id diff --git a/src/main/java/edu/harvard/iq/dataverse/BannerMessageText.java b/src/main/java/edu/harvard/iq/dataverse/BannerMessageText.java index ea2dd1b41fc..cb223384341 100644 --- a/src/main/java/edu/harvard/iq/dataverse/BannerMessageText.java +++ b/src/main/java/edu/harvard/iq/dataverse/BannerMessageText.java @@ -13,12 +13,14 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import org.eclipse.microprofile.openapi.annotations.media.Schema; /** * * @author skraffmi */ @Entity +@Schema(description = "Localized banner message text, including the language code and message content.") public class BannerMessageText implements Serializable { @Id diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetLock.java b/src/main/java/edu/harvard/iq/dataverse/DatasetLock.java index cc0078ecbc5..c76c717f26f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetLock.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetLock.java @@ -38,6 +38,7 @@ import jakarta.persistence.TemporalType; import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQuery; +import org.eclipse.microprofile.openapi.annotations.media.Schema; /** * @@ -64,6 +65,7 @@ ) public class DatasetLock implements Serializable { + @Schema(description = "Dataset lock reason indicating why dataset editing or publication is blocked.") public enum Reason { /** Data being ingested *//** Data being ingested */ Ingest, diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseLinkingServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DataverseLinkingServiceBean.java index 9f1bcde4c0e..cd806d250d4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseLinkingServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseLinkingServiceBean.java @@ -89,19 +89,19 @@ public void save(DataverseLinkingDataverse dataverseLinkingDataverse) { } } - public DataverseLinkingDataverse findDataverseLinkingDataverse(Long dataverseId, Long linkingDataverseId) { + public DataverseLinkingDataverse findDataverseLinkingDataverse(Long linkingDataverseId, Long linkedDataverseId) { try { return em.createNamedQuery("DataverseLinkingDataverse.findByDataverseIdAndLinkingDataverseId", DataverseLinkingDataverse.class) - .setParameter("dataverseId", dataverseId) + .setParameter("dataverseId", linkedDataverseId) .setParameter("linkingDataverseId", linkingDataverseId) .getSingleResult(); } catch (jakarta.persistence.NoResultException e) { - logger.fine("No DataverseLinkingDataverse found for dataverseId " + dataverseId + " and linkedDataverseId " + linkingDataverseId); + logger.fine("No DataverseLinkingDataverse found for linkingDataverseId " + linkingDataverseId + " and linkedDataverseId " + linkedDataverseId); return null; } } - public boolean alreadyLinked(Dataverse definitionPoint, Dataverse dataverseToLinkTo) { - return findDataverseLinkingDataverse(dataverseToLinkTo.getId(), definitionPoint.getId()) != null; + public boolean alreadyLinked(Dataverse linkingDataverse, Dataverse linkedDataverse) { + return findDataverseLinkingDataverse(linkingDataverse.getId(), linkedDataverse.getId()) != null; } } diff --git a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java index a8fca620fbe..76ef1002f7e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataversePage.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataversePage.java @@ -215,18 +215,31 @@ public void setLinkMode(LinkMode linkMode) { } public boolean showLinkingPopup() { - String testquery = ""; - if (session.getUser() == null) { + // Must be logged in + AuthenticatedUser au = getAuthenticatedUser(); + if (au == null) { return false; } if (dataverse == null) { return false; } - if (query != null) { - testquery = query; + + // If there is an active search query, that's all that matters (plus having permission on ANY collection) + if (query != null && !query.isEmpty()) { + List permitted = permissionService.findPermittedCollections(dvRequestService.getDataverseRequest(), au, Permission.LinkDataverse); + return permitted != null && !permitted.isEmpty(); + } + + // Otherwise (no active search), check if there is at least one OTHER eligible collection + // Eligible means: not the current collection and not in the parent tree + // Technically, eligible also means "not already linked", but in that case, we show the Link button anyway and have the Link dialog display a message about all eligible collections already being linked + List dvsWithLinkPermission = permissionService.findPermittedCollections(dvRequestService.getDataverseRequest(), au, Permission.LinkDataverse); + if (dvsWithLinkPermission != null && !dvsWithLinkPermission.isEmpty()) { + List eligibleDataverses = dataverseService.removeUnlinkableDataverses(dvsWithLinkPermission, dataverse, false); + return !eligibleDataverses.isEmpty(); } - return (session.getUser().isSuperuser() && (dataverse.getOwner() != null || !testquery.isEmpty())); + return false; } public void setupLinkingPopup (String popupSetting){ @@ -241,35 +254,18 @@ public void setupLinkingPopup (String popupSetting){ public void updateLinkableDataverses() { dataversesForLinking = new ArrayList<>(); linkingDVSelectItems = new ArrayList<>(); - - //Since only a super user function add all dvs - dataversesForLinking = dataverseService.findAll();// permissionService.getDataversesUserHasPermissionOn(session.getUser(), Permission.PublishDataverse); - - /* - List roles = dataverseRoleServiceBean.getDataverseRolesByPermission(Permission.PublishDataverse, dataverse.getId()); - List types = new ArrayList(); - types.add("Dataverse"); - for (Long dvIdAsInt : permissionService.getDvObjectIdsUserHasRoleOn(session.getUser(), roles, types, false)) { - dataversesForLinking.add(dataverseService.find(dvIdAsInt)); - }*/ - - //for linking - make sure the link hasn't occurred and its not int the tree - if (this.linkMode.equals(LinkMode.LINKDATAVERSE)) { - - // remove this and it's parent tree - dataversesForLinking.remove(dataverse); - Dataverse testDV = dataverse; - while(testDV.getOwner() != null){ - dataversesForLinking.remove(testDV.getOwner()); - testDV = testDV.getOwner(); - } - - for (Dataverse removeLinked : linkingService.findLinkingDataverses(dataverse.getId())) { - dataversesForLinking.remove(removeLinked); - } - } else{ - //for saved search add all + + List dvsWithLinkPermission = permissionService.findPermittedCollections(dvRequestService.getDataverseRequest(), getAuthenticatedUser(), Permission.LinkDataverse, ""); + + if (dvsWithLinkPermission != null && !dvsWithLinkPermission.isEmpty()) { + // for linking - make sure the link hasn't occurred and it's not in the tree + if (this.linkMode.equals(LinkMode.LINKDATAVERSE)) { + dataversesForLinking = dataverseService.removeUnlinkableDataverses(dvsWithLinkPermission, dataverse); + } else { + // for saved search, add all + dataversesForLinking = dvsWithLinkPermission; + } } for (Dataverse selectDV : dataversesForLinking) { diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java index 29cfb3fb42a..1816a76ed5c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java @@ -551,28 +551,30 @@ public List filterDataversesForLinking(String query, DataverseRequest return dataverseList; } - public List removeUnlinkableDataverses(List allWithPerms, DvObject dvo) { + public List removeUnlinkableDataverses(List allWithPerms, DvObject dvo, boolean removeAlreadyLinked) { List dataverseList = new ArrayList<>(); Dataset linkedDataset = null; Dataverse linkedDataverse = null; - List alreadyLinkeddv_ids; - - if ((dvo instanceof Dataset)) { - linkedDataset = (Dataset) dvo; - alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM datasetlinkingdataverse WHERE dataset_id = " + linkedDataset.getId()).getResultList(); - } else { - linkedDataverse = (Dataverse) dvo; - alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM dataverselinkingdataverse WHERE dataverse_id = " + linkedDataverse.getId()).getResultList(); - } List remove = new ArrayList<>(); - if (alreadyLinkeddv_ids != null && !alreadyLinkeddv_ids.isEmpty()) { - alreadyLinkeddv_ids.stream().map((testDVId) -> this.find(testDVId)).forEachOrdered((removeIt) -> { - remove.add(removeIt); - }); + if (removeAlreadyLinked) { + List alreadyLinkeddv_ids; + + if ((dvo instanceof Dataset)) { + linkedDataset = (Dataset) dvo; + alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM datasetlinkingdataverse WHERE dataset_id = " + linkedDataset.getId()).getResultList(); + } else { + linkedDataverse = (Dataverse) dvo; + alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM dataverselinkingdataverse WHERE dataverse_id = " + linkedDataverse.getId()).getResultList(); + } + + if (alreadyLinkeddv_ids != null && !alreadyLinkeddv_ids.isEmpty()) { + alreadyLinkeddv_ids.stream().map((testDVId) -> this.find(testDVId)).forEachOrdered((removeIt) -> { + remove.add(removeIt); + }); + } } - if (dvo instanceof Dataverse dataverse) { remove.add(dataverse); @@ -596,8 +598,11 @@ public List removeUnlinkableDataverses(List allWithPerms, return dataverseList; } - - + + public List removeUnlinkableDataverses(List allWithPerms, DvObject dvo) { + return removeUnlinkableDataverses(allWithPerms, dvo, true); + } + public List filterDataversesForUnLinking(String query, DataverseRequest req, Dataset dataset) { List alreadyLinkeddv_ids = em.createNativeQuery("SELECT linkingdataverse_id FROM datasetlinkingdataverse WHERE dataset_id = " + dataset.getId()).getResultList(); List dataverseList = new ArrayList<>(); diff --git a/src/main/java/edu/harvard/iq/dataverse/DvObject.java b/src/main/java/edu/harvard/iq/dataverse/DvObject.java index 83f21ebab20..1f459efed78 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DvObject.java +++ b/src/main/java/edu/harvard/iq/dataverse/DvObject.java @@ -11,6 +11,7 @@ import java.util.logging.Logger; import jakarta.persistence.*; +import org.eclipse.microprofile.openapi.annotations.media.Schema; /** * Base of the object hierarchy for "anything that can be inside a dataverse". @@ -58,6 +59,7 @@ public abstract class DvObject extends DataverseEntity implements java.io.Serial private static final Logger logger = Logger.getLogger(DvObject.class.getCanonicalName()); + @Schema(description = "Dataverse object type discriminator for dataverses, datasets, and data files.") public enum DType { Dataverse("Dataverse"), Dataset("Dataset"),DataFile("DataFile"); 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 a2d7d3ed525..7d12e5e8b3f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Access.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Access.java @@ -44,7 +44,10 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.*; import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; 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; @@ -88,6 +91,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()); @@ -137,8 +141,12 @@ public class Access extends AbstractApiBean { @GET @AuthRequired @Path("datafile/{fileId}/citation/{format}") + @Operation(summary = "Returns a data file citation", + description = "Formats the requested data file citation in the selected citation format after validating access.") public Response datafileCitation(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id or persistent identifier.", required = true) @PathParam("fileId") String fileId, + @Parameter(description = "Citation format to return.") @PathParam("format") String formatString) { DataCitation.Format format = DataCitation.getFormat(formatString); @@ -162,8 +170,21 @@ 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.") + @APIResponse(responseCode = "200", + description = "ZIP archive containing the data file bundle, citation files, and available metadata.", + content = @Content(mediaType = "application/zip", + schema = @Schema(type = SchemaType.STRING, format = "binary"))) + 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*/ { DataverseRequest req = createDataverseRequest(getRequestUser(crc)); DataFile df = findDataFileUserCanSeeOrDieWrapper(fileId, req); @@ -223,8 +244,24 @@ 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.") + @APIResponse(responseCode = "200", + description = "ZIP archive containing the data file bundle, citation files, and available metadata.", + content = @Content(mediaType = "application/zip", + schema = @Schema(type = SchemaType.STRING, format = "binary"))) + 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*/ { DataverseRequest req = createDataverseRequest(getRequestUser(crc)); processDatafileWithGuestbookResponse(crc, req, headers, fileId, uriInfo, gbrecs, jsonBody); // JSF UI passes the guestbook response id(s) in thus this qp can be removed when JSF is removed @@ -258,7 +295,15 @@ private DataFile findDataFileUserCanSeeOrDieWrapper(String fileId, DataverseRequ @AuthRequired @Path("datafile/{fileId:.+}") @Produces({"application/xml","*/*"}) - public Response datafile(@Context ContainerRequestContext crc, @PathParam("fileId") String fileId, @QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids, + @Operation(summary = "Downloads a data file", + description = "Streams the requested data file after validating access and guestbook requirements.") + public Response datafile(@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, + @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*/ { DataverseRequest req = createDataverseRequest(getRequestUser(crc)); @@ -406,8 +451,16 @@ 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.") + 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) { DataverseRequest req = createDataverseRequest(getRequestUser(crc)); fileId = normalizeFileId(fileId, req); @@ -580,7 +633,18 @@ 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.") + 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); } @@ -592,7 +656,18 @@ 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.") + 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; @@ -665,7 +740,10 @@ public String tabularDatafileMetadataDDI(@Context ContainerRequestContext crc, @ @GET @AuthRequired @Path("datafile/{fileId}/auxiliary") + @Operation(summary = "Lists auxiliary files", + description = "Lists auxiliary files associated with the requested data file.") public Response listDatafileMetadataAux(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id or persistent identifier.", required = true) @PathParam("fileId") String fileId, @Context UriInfo uriInfo, @Context HttpHeaders headers, @@ -680,8 +758,12 @@ public Response listDatafileMetadataAux(@Context ContainerRequestContext crc, @GET @AuthRequired @Path("datafile/{fileId}/auxiliary/{origin}") + @Operation(summary = "Lists auxiliary files by origin", + description = "Lists auxiliary files associated with the requested data file and origin.") public Response listDatafileMetadataAuxByOrigin(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id or persistent identifier.", required = true) @PathParam("fileId") String fileId, + @Parameter(description = "Auxiliary file origin to match.", required = true) @PathParam("origin") String origin, @Context UriInfo uriInfo, @Context HttpHeaders headers, @@ -725,9 +807,18 @@ private Response listAuxiliaryFiles(User user, String fileId, String origin, Uri @GET @AuthRequired @Path("datafile/{fileId}/auxiliary/{formatTag}/{formatVersion}") + @Operation(summary = "Downloads an auxiliary file", + description = "Streams the requested auxiliary file format version after validating file access.") + @APIResponse(responseCode = "200", + description = "Auxiliary file bytes for the requested data file format.", + content = @Content(mediaType = "application/octet-stream", + schema = @Schema(type = SchemaType.STRING, format = "binary"))) public DownloadInstance downloadAuxiliaryFile(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id or persistent identifier.", required = true) @PathParam("fileId") String fileId, + @Parameter(description = "Auxiliary file format tag.", required = true) @PathParam("formatTag") String formatTag, + @Parameter(description = "Auxiliary file format version.", required = true) @PathParam("formatVersion") String formatVersion, @Context UriInfo uriInfo, @Context HttpHeaders headers, @@ -802,7 +893,16 @@ 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.") + 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 { DataverseRequest req = createDataverseRequest(getRequestUser(crc)); processDatafileWithGuestbookResponse(crc, req, headers, body, uriInfo, gbrecs, body); @@ -819,8 +919,19 @@ public Response postDownloadDatafiles(@Context ContainerRequestContext crc, Stri @AuthRequired @Path("dataset/{id}") @Produces({"application/zip"}) - public Response downloadAllFromLatest(@Context ContainerRequestContext crc, @PathParam("id") String datasetIdOrPersistentId, - @QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids, + @Operation(summary = "Downloads files from the latest dataset version", + description = "Streams a ZIP archive containing files from the draft version when the requester may view it, otherwise from the latest released version.") + @APIResponse(responseCode = "200", + description = "ZIP archive containing files from the latest accessible dataset version.", + content = @Content(mediaType = "application/zip", + schema = @Schema(type = SchemaType.STRING, format = "binary"))) + public Response downloadAllFromLatest(@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, + @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 { try { User user = getRequestUser(crc); @@ -865,7 +976,16 @@ 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.") + 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); @@ -896,8 +1016,26 @@ public Response downloadAllFromLatestWithGuestbookResponse(@Context ContainerReq @AuthRequired @Path("dataset/{id}/versions/{versionId}") @Produces({"application/zip"}) - public Response downloadAllFromVersion(@Context ContainerRequestContext crc, @PathParam("id") String datasetIdOrPersistentId, @PathParam("versionId") String versionId, - @QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids, @QueryParam("key") String apiTokenParam, @QueryParam("signed") boolean signed, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) throws WebApplicationException { + @Operation(summary = "Downloads files from a dataset version", + description = "Streams a ZIP archive containing files from the requested dataset version after resolving version aliases such as draft or latest-published.") + @APIResponse(responseCode = "200", + description = "ZIP archive containing files from the requested dataset version.", + content = @Content(mediaType = "application/zip", + schema = @Schema(type = SchemaType.STRING, format = "binary"))) + public Response downloadAllFromVersion(@Context ContainerRequestContext crc, + @Parameter(description = "Dataset id or persistent identifier.", required = true) + @PathParam("id") String datasetIdOrPersistentId, + @Parameter(description = "Dataset version selector, such as draft, latest, latest-published, or a version number.", required = true) + @PathParam("versionId") String versionId, + @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, + @Parameter(description = "Legacy API token query value.") + @QueryParam("key") String apiTokenParam, + @Parameter(description = "Whether the request URL was signed.") + @QueryParam("signed") boolean signed, + @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) throws WebApplicationException { try { DatasetVersion dsv = getDatasetVersionFromVersion(crc, datasetIdOrPersistentId, versionId); if (dsv == null) { @@ -925,8 +1063,22 @@ public Response downloadAllFromVersion(@Context ContainerRequestContext crc, @Pa @AuthRequired @Path("dataset/{id}/versions/{versionId}") @Produces({"application/zip"}) - public Response downloadAllFromVersionWithGuestbookResponse(@Context ContainerRequestContext crc, @PathParam("id") String datasetIdOrPersistentId, @PathParam("versionId") String versionId, - @QueryParam("gbrecs") boolean gbrecs, @QueryParam("key") String apiTokenParam, String jsonBody, + @Operation(summary = "Submit guestbook response for dataset version files", + description = "Records a guestbook response and prepares a ZIP download for files in the requested dataset version.") + @APIResponse(responseCode = "200", + description = "Signed URL details for downloading the requested dataset version files.", + content = @Content(mediaType = "application/json")) + public Response downloadAllFromVersionWithGuestbookResponse(@Context ContainerRequestContext crc, + @Parameter(description = "Dataset id or persistent identifier.", required = true) + @PathParam("id") String datasetIdOrPersistentId, + @Parameter(description = "Dataset version selector, such as draft, latest, latest-published, or a version number.", required = true) + @PathParam("versionId") String versionId, + @Parameter(description = "Whether guestbook records have already been written for this download.") + @QueryParam("gbrecs") boolean gbrecs, + @Parameter(description = "Legacy API token query value.") + @QueryParam("key") String apiTokenParam, + @RequestBody(description = "Guestbook response JSON for the dataset version file download.") + String jsonBody, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) throws WebApplicationException { try { DataverseRequest req = createDataverseRequest(getRequestUser(crc)); @@ -999,7 +1151,19 @@ private String generateMultiFileBundleName(Dataset dataset, String versionTag) { @AuthRequired @Path("datafiles/{fileIds}") @Produces({"application/zip"}) - public Response datafiles(@Context ContainerRequestContext crc, @PathParam("fileIds") String fileIds, @QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids, + @Operation(summary = "Downloads selected data files", + description = "Streams a ZIP archive containing the selected data files after validating access and guestbook requirements.") + @APIResponse(responseCode = "200", + description = "ZIP archive containing the selected data files.", + content = @Content(mediaType = "application/zip", + schema = @Schema(type = SchemaType.STRING, format = "binary"))) + public Response datafiles(@Context ContainerRequestContext crc, + @Parameter(description = "Comma-separated data file ids to include in the ZIP archive.", required = true) + @PathParam("fileIds") String fileIds, + @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 { return downloadDatafiles(crc, fileIds, gbrecs, gbrids, uriInfo, headers, response, null); @@ -1009,8 +1173,19 @@ public Response datafiles(@Context ContainerRequestContext crc, @PathParam("file @AuthRequired @Path("datafiles/{fileIds}") @Produces({"application/zip"}) - public Response datafilesWithGuestbookResponse(@Context ContainerRequestContext crc, @PathParam("fileIds") String fileIds, @QueryParam("gbrecs") boolean gbrecs, - @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response, String jsonBody) throws WebApplicationException { + @Operation(summary = "Submit guestbook response for selected data files", + description = "Records a guestbook response and prepares a ZIP download for the selected data files.") + @APIResponse(responseCode = "200", + description = "Signed URL details for downloading the selected data files.", + content = @Content(mediaType = "application/json")) + public Response datafilesWithGuestbookResponse(@Context ContainerRequestContext crc, + @Parameter(description = "Comma-separated data file ids to include in the ZIP archive.", required = true) + @PathParam("fileIds") String fileIds, + @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 selected data file download.") + String jsonBody) throws WebApplicationException { DataverseRequest req = createDataverseRequest(getRequestUser(crc)); return processDatafileWithGuestbookResponse(crc, req, headers, fileIds, uriInfo, gbrecs, jsonBody); @@ -1259,10 +1434,19 @@ public InputStream tempPreview(@PathParam("fileSystemId") String fileSystemId, @ @GET @Produces({ "image/png" }) @AuthRequired - public InputStream fileCardImage(@Context ContainerRequestContext crc, @PathParam("fileId") Long fileId, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ { - - - + @Operation(summary = "Returns a data file card image", + description = "Returns a generated thumbnail image for a data file when the file type supports thumbnail generation.") + @APIResponse(responseCode = "200", + description = "PNG thumbnail image for the data file.", + content = @Content(mediaType = "image/png", + schema = @Schema(type = SchemaType.STRING, format = "binary"))) + public InputStream fileCardImage(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id used to locate the card image.", required = true) + @PathParam("fileId") Long fileId, + @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ { + + + DataFile df = dataFileService.find(fileId); DataverseRequest req = createDataverseRequest(getRequestUser(crc)); if (df == null || (df.isLocallyFAIR() && !permissionSvc.hasLocallyFAIRAccess(req, df))) { @@ -1298,7 +1482,16 @@ public InputStream fileCardImage(@Context ContainerRequestContext crc, @PathPara @GET @Produces({ "image/png" }) @AuthRequired - public InputStream dvCardImage(@Context ContainerRequestContext crc, @PathParam("dataverseId") Long dataverseId, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ { + @Operation(summary = "Returns a dataverse card image", + description = "Returns a thumbnail image generated from the dataverse logo when one is configured.") + @APIResponse(responseCode = "200", + description = "PNG thumbnail image for the dataverse.", + content = @Content(mediaType = "image/png", + schema = @Schema(type = SchemaType.STRING, format = "binary"))) + public InputStream dvCardImage(@Context ContainerRequestContext crc, + @Parameter(description = "Dataverse id used to locate the card image.", required = true) + @PathParam("dataverseId") Long dataverseId, + @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ { logger.fine("entering dvCardImage"); Dataverse dataverse = dataverseService.find(dataverseId); @@ -1476,15 +1669,24 @@ private String getWebappImageResource(String imageName) { }) @Tag(name = "saveAuxiliaryFileWithVersion", description = "Save Auxiliary File With Version") - @RequestBody(content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA)) + @RequestBody(description = "Multipart auxiliary file content and metadata to store for the data file format version.", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA)) public Response saveAuxiliaryFileWithVersion(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id for the auxiliary file.", required = true) @PathParam("fileId") Long fileId, + @Parameter(description = "Auxiliary file format tag.", required = true) @PathParam("formatTag") String formatTag, + @Parameter(description = "Auxiliary file format version.", required = true) @PathParam("formatVersion") String formatVersion, + @Parameter(description = "Application or source that created the auxiliary file.") @FormDataParam("origin") String origin, + @Parameter(description = "Marks whether the auxiliary file can be accessed publicly.") @FormDataParam("isPublic") boolean isPublic, + @Parameter(description = "Auxiliary file type or grouping label.") @FormDataParam("type") String type, + @Parameter(description = "Auxiliary file part, including submitted media type.", required = true) @FormDataParam("file") final FormDataBodyPart formDataBodyPart, + @Parameter(description = "Auxiliary file content stream.", required = true) @FormDataParam("file") InputStream fileInputStream) { AuthenticatedUser authenticatedUser; try { @@ -1528,9 +1730,14 @@ public Response saveAuxiliaryFileWithVersion(@Context ContainerRequestContext cr @DELETE @AuthRequired @Path("datafile/{fileId}/auxiliary/{formatTag}/{formatVersion}") + @Operation(summary = "Deletes an auxiliary file", + description = "Deletes the requested auxiliary file format version after validating edit permissions.") public Response deleteAuxiliaryFileWithVersion(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id for the auxiliary file.", required = true) @PathParam("fileId") Long fileId, + @Parameter(description = "Auxiliary file format tag.", required = true) @PathParam("formatTag") String formatTag, + @Parameter(description = "Auxiliary file format version.", required = true) @PathParam("formatVersion") String formatVersion) { AuthenticatedUser authenticatedUser; try { @@ -1573,7 +1780,13 @@ public Response deleteAuxiliaryFileWithVersion(@Context ContainerRequestContext @PUT @AuthRequired @Path("{id}/allowAccessRequest") - public Response allowAccessRequest(@Context ContainerRequestContext crc, @PathParam("id") String datasetToAllowAccessId, String requestStr) { + @Operation(summary = "Updates dataset file access request settings", + description = "Enables or disables file access requests for the dataset terms in the editable dataset version.") + public Response allowAccessRequest(@Context ContainerRequestContext crc, + @Parameter(description = "Dataset id or persistent identifier.", required = true) + @PathParam("id") String datasetToAllowAccessId, + @RequestBody(description = "Boolean value indicating whether file access requests are allowed.") + String requestStr) { DataverseRequest dataverseRequest = null; Dataset dataset; @@ -1616,8 +1829,13 @@ public Response allowAccessRequest(@Context ContainerRequestContext crc, @PathPa @PUT @AuthRequired @Path("/datafile/{id}/requestAccess") + @Operation(summary = "Requests access to a restricted data file", + description = "Creates a file access request for the authenticated user when requests are accepted and access has not already been granted.") public Response requestFileAccess(@Context ContainerRequestContext crc - ,@PathParam("id") String fileToRequestAccessId, String jsonBody) { + ,@Parameter(description = "Restricted data file id or persistent identifier.", required = true) + @PathParam("id") String fileToRequestAccessId, + @RequestBody(description = "Optional guestbook response JSON submitted with the access request.") + String jsonBody) { DataverseRequest dataverseRequest; DataFile dataFile; @@ -1692,9 +1910,16 @@ public Response requestFileAccess(@Context ContainerRequestContext crc @GET @AuthRequired @Path("/datafile/{id}/listRequests") - public Response listFileAccessRequests(@Context ContainerRequestContext crc, @PathParam("id") String fileToRequestAccessId, + @Operation(summary = "Lists data file access requests", + description = "Lists active or historical access requests for a restricted data file.") + public Response listFileAccessRequests(@Context ContainerRequestContext crc, + @Parameter(description = "Restricted data file id or persistent identifier.", required = true) + @PathParam("id") String fileToRequestAccessId, + @Parameter(description = "Includes historical access requests when true.") @QueryParam("includeHistory") boolean includeHistory, + @Parameter(description = "Number of historical access requests to include per page.") @QueryParam("per_page") final int numResultsPerPageRequested, + @Parameter(description = "Pagination offset for historical access requests.") @QueryParam("start") final int paginationStart, @Context HttpHeaders headers) { DataverseRequest dataverseRequest; @@ -1764,7 +1989,14 @@ public Response listFileAccessRequests(@Context ContainerRequestContext crc, @Pa @PUT @AuthRequired @Path("/datafile/{id}/grantAccess/{identifier}") - public Response grantFileAccess(@Context ContainerRequestContext crc, @PathParam("id") String fileToRequestAccessId, @PathParam("identifier") String identifier, @Context HttpHeaders headers) { + @Operation(summary = "Grants access to a data file", + description = "Assigns the file downloader role to the requested assignee for a restricted data file.") + public Response grantFileAccess(@Context ContainerRequestContext crc, + @Parameter(description = "Restricted data file id or persistent identifier.", required = true) + @PathParam("id") String fileToRequestAccessId, + @Parameter(description = "User or group identifier receiving file access.", required = true) + @PathParam("identifier") String identifier, + @Context HttpHeaders headers) { DataverseRequest dataverseRequest; DataFile dataFile; @@ -1826,7 +2058,14 @@ public Response grantFileAccess(@Context ContainerRequestContext crc, @PathParam @DELETE @AuthRequired @Path("/datafile/{id}/revokeAccess/{identifier}") - public Response revokeFileAccess(@Context ContainerRequestContext crc, @PathParam("id") String fileToRequestAccessId, @PathParam("identifier") String identifier, @Context HttpHeaders headers) { + @Operation(summary = "Revokes access to a restricted data file", + description = "Removes the file downloader role from the requested assignee for a restricted data file.") + public Response revokeFileAccess(@Context ContainerRequestContext crc, + @Parameter(description = "Restricted data file id or persistent identifier.", required = true) + @PathParam("id") String fileToRequestAccessId, + @Parameter(description = "User or group identifier losing file access.", required = true) + @PathParam("identifier") String identifier, + @Context HttpHeaders headers) { DataverseRequest dataverseRequest; DataFile dataFile; @@ -1892,7 +2131,14 @@ public Response revokeFileAccess(@Context ContainerRequestContext crc, @PathPara @PUT @AuthRequired @Path("/datafile/{id}/rejectAccess/{identifier}") - public Response rejectFileAccess(@Context ContainerRequestContext crc, @PathParam("id") String fileToRequestAccessId, @PathParam("identifier") String identifier, @Context HttpHeaders headers) { + @Operation(summary = "Rejects a data file access request", + description = "Rejects the requested assignee's access request for a restricted data file.") + public Response rejectFileAccess(@Context ContainerRequestContext crc, + @Parameter(description = "Restricted data file id or persistent identifier.", required = true) + @PathParam("id") String fileToRequestAccessId, + @Parameter(description = "User or group identifier whose access request is rejected.", required = true) + @PathParam("identifier") String identifier, + @Context HttpHeaders headers) { DataverseRequest dataverseRequest; DataFile dataFile; @@ -1940,7 +2186,11 @@ public Response rejectFileAccess(@Context ContainerRequestContext crc, @PathPara @GET @AuthRequired @Path("/datafile/{id}/userFileAccessRequested") - public Response getUserFileAccessRequested(@Context ContainerRequestContext crc, @PathParam("id") String dataFileId) { + @Operation(summary = "Checks whether file access was requested", + description = "Returns whether the authenticated user has an access request recorded for the restricted data file.") + public Response getUserFileAccessRequested(@Context ContainerRequestContext crc, + @Parameter(description = "Restricted data file id or persistent identifier.", required = true) + @PathParam("id") String dataFileId) { DataFile dataFile; AuthenticatedUser requestAuthenticatedUser; try { @@ -1964,7 +2214,11 @@ public Response getUserFileAccessRequested(@Context ContainerRequestContext crc, @GET @AuthRequired @Path("/datafile/{id}/userPermissions") - public Response getUserPermissionsOnFile(@Context ContainerRequestContext crc, @PathParam("id") String dataFileId) { + @Operation(summary = "Returns current user file permissions", + description = "Returns booleans indicating whether the current user may download the file, manage file permissions, or edit the owner dataset.") + public Response getUserPermissionsOnFile(@Context ContainerRequestContext crc, + @Parameter(description = "Data file id or persistent identifier.", required = true) + @PathParam("id") String dataFileId) { DataFile dataFile; try { DataverseRequest req = createDataverseRequest(getRequestAuthenticatedUserOrDie(crc)); @@ -1982,7 +2236,8 @@ public Response getUserPermissionsOnFile(@Context ContainerRequestContext crc, @ private boolean checkGuestbookRequiredResponse(User user, UriInfo uriInfo, DataFile df, String gbrids) throws WebApplicationException { // Check if guestbook response is required - boolean required = df.getOwner().hasEnabledGuestbook(); + Dataset d = df.getOwner(); + boolean required = df.getOwner().hasEnabledGuestbook() && !d.getEffectiveGuestbookEntryAtRequest(); boolean wasWrittenInPost = false; if (required) { User requestor = getRequestor(user); @@ -2236,7 +2491,15 @@ private URI handleCustomZipDownload(User user, String customZipServiceUrl, Strin @AuthRequired @Produces({"image/png"}) @Path("dataverseFeaturedItemImage/{itemId}") - public InputStream getDataverseFeatureItemImage(@Context ContainerRequestContext crc, @PathParam("itemId") Long itemId) { + @Operation(summary = "Returns a featured item image", + description = "Returns the image file associated with a dataverse featured item when the requester may view the item.") + @APIResponse(responseCode = "200", + description = "PNG image associated with the dataverse featured item.", + content = @Content(mediaType = "image/png", + schema = @Schema(type = SchemaType.STRING, format = "binary"))) + public InputStream getDataverseFeatureItemImage(@Context ContainerRequestContext crc, + @Parameter(description = "Dataverse featured item id.", required = true) + @PathParam("itemId") Long itemId) { DataverseFeaturedItem dataverseFeaturedItem; try { dataverseFeaturedItem = execCommand(new GetDataverseFeaturedItemCommand(createDataverseRequest(getRequestUser(crc)), dataverseFeaturedItemServiceBean.findById(itemId))); 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 919fa7f67f9..d4b0daa04e1 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,14 @@ 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.tags.Tag; import java.nio.file.Paths; import java.util.TreeMap; @@ -150,6 +154,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 +209,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 +224,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 +247,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 +265,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 +286,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 +303,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 +323,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 +339,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 +358,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 +389,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