diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 13430c4a..21d4d1c4 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -26,7 +26,8 @@ jobs: strategy: matrix: operating-system: [ubuntu-latest, macOS-latest, windows-latest] - jdk: [ 21 ] # (open)JDK releases + jdk: [ 21 ] + distro: ['temurin'] steps: - uses: actions/checkout@v4 @@ -34,7 +35,7 @@ jobs: uses: actions/setup-java@v4 with: java-version: ${{ matrix.jdk }} - distribution: 'zulu' # =openJDK + distribution: ${{ matrix.distro }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - name: Build and Test with Gradle @@ -45,7 +46,8 @@ jobs: - name: Docker build and test if: matrix.operating-system == 'ubuntu-latest' && matrix.jdk == 21 run: | - curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/4.0.0/hurl_4.0.0_amd64.deb - sudo dpkg -i hurl_4.0.0_amd64.deb + VERSION=6.0.0 + curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb + sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.deb time bash ./docker/test_docker.sh shell: bash diff --git a/.gitignore b/.gitignore index e1814ee1..c9f1da77 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,8 @@ $RECYCLE.BIN/ **/*.properties !/config/application-default.properties !/config/application-docker.properties -############## \ No newline at end of file +############## + +test_prefix_data/ +config/*.bin +config/*.pem \ No newline at end of file diff --git a/CITATION.cff b/CITATION.cff index 6497d9fd..c52b8021 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -14,7 +14,8 @@ authors: title: "Typed PID Maker" type: software abstract: The Typed PID Maker is the entry point to integrate digital resources into the FAIR DO ecosystem. It allows to create PIDs for resources and to provide them with the necessary metadata to ensure that the resources can be found. -date-released: 2020-10-01 +date-released: 2025-06-12 url: "https://github.com/kit-data-manager/pit-service" repository-code: "https://github.com/kit-data-manager/pit-service" license: Apache-2.0 +version: 2.2.1 diff --git a/README.md b/README.md index 9027d502..138f5b15 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,27 @@ PID = prefix + (branding + uniquely-generated-string) All other configuration properties affect only the `uniquely-generated-string`. For example, you may choose a different generation method (UUID (default) or Hex Chunks) enforce casing (lower-case, upper-case). +## What PID record validation means + +Our validation steps include the following: + +1. For each attribute, check if the values are valid according to their type specification. + - Values are considered to be in JSON format. If a type is complex, this implies a string-serialized JSON object. +2. For each attribute which indicates a profile, resolve the profile definitions from the values of the attributes. + - Attributes which indicates profiles are internally known, but may be added using the [configuration](https://github.com/kit-data-manager/pit-service/blob/master/config/application-default.properties). +3. For each profile definition, check if mandatory attributes are present. +4. For each profile definition, check if only repeatable attributes have multiple occurrences. + +This implies the following properties: + +- A profile is not required, + - but all profiles which are present are being used for validation, + - and all of them have to pass. +- Additional attributes are allowed if specified in the configuration of the Typed PID Maker instance. + - Otherwise, they are not allowed. Which makes it almost impossible to use multiple profiles. + - Only dtr-test supports an "additionalAttributesAllowed" boolean property per profile, + - But as it will not last and other DTRs do currently not support it, we don't support it either. + ## How to build > Note: Alternatively, you can use the docker image. @@ -111,9 +132,8 @@ All other configuration properties affect only the `uniquely-generated-string`. - Building (with tests): `./gradlew clean build` - Building (with verbose test output) `./gradlew -Dprofile=verbose clean build` - Building (without tests): `./gradlew clean build -x test` -- Run docker integration tests: - - `./gradlew clean build` (by default, this will reuse the local build) - - `time bash ./docker/test_docker.sh` (runs test script) +- Run docker integration tests: `time bash ./docker/test_docker.sh` (will reuse the local build) +- Run dockerized validation benchmarks: `time bash ./docker/test_docker.sh benchmark` (will reuse the local build) - Doing a release: `./gradlew clean build release` - Will prompt you about version number to use and next version number - Will make a git tag which can later be used in a GitHub release diff --git a/build.gradle b/build.gradle index be62d801..582ef944 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,7 @@ repositories { } ext { - springDocVersion = '2.8.8' + springDocVersion = '2.8.9' } dependencies { @@ -56,8 +56,8 @@ dependencies { // dependencies. It will automatically choose the fitting ones. implementation("edu.kit.datamanager:service-base:1.3.4") implementation("edu.kit.datamanager:repo-core:1.2.5") - // com.google.common, LoadingCache - implementation("com.google.guava:guava:33.4.8-jre") + // AsyncLoadingCache https://github.com/ben-manes/caffeine + implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") // spring core, e.g. @EnableJpaRepositories implementation "org.springframework:spring-core" @@ -81,8 +81,7 @@ dependencies { implementation "org.springframework.data:spring-data-elasticsearch" // More flexibility when (de-)serializing json: - //implementation("com.monitorjbl:spring-json-view:1.1.0") - implementation("com.github.everit-org.json-schema:org.everit.json.schema:1.14.4") + implementation(group: 'com.networknt', name: 'json-schema-validator', version: '1.5.7'); implementation('org.apache.httpcomponents:httpclient:4.5.14') implementation('org.apache.httpcomponents:httpclient-cache:4.5.14') @@ -155,7 +154,7 @@ test { println "Tests will have verbose output" testLogging { // tests are never "up-to-date", always print everything - outputs.upToDateWhen {false} + outputs.upToDateWhen { false } // show stdio when tests are running showStandardStreams = true // for junit5 diff --git a/config/application-default.properties b/config/application-default.properties index 7454a950..48f4c7ad 100644 --- a/config/application-default.properties +++ b/config/application-default.properties @@ -11,40 +11,44 @@ # More specific properties are documented within this file. ### General Spring Boot Settings ### -# When to include "message" attribute in HTTP responses on uncatched exceptions. -server.error.include-message: always +# When to include the "message" attribute in HTTP responses on uncatched exceptions. +server.error.include-message=always +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true springdoc.show-actuator=true # Do __not__ change these settings below: spring.main.allow-bean-definition-overriding=true -spring.data.rest.detection-strategy:annotated +spring.data.rest.detection-strategy=annotated ##################################################### + ########################### ### Port, SSL, Security ### ########################### -server.port: 8090 +server.port=8090 #server.ssl.key-store: keystore.p12 #server.ssl.key-store-password: test123 #server.ssl.keyStoreType: PKCS12 #server.ssl.keyAlias: tomcat -# Data transfer settings, e.g. transfer compression and multipart message size. +# Data transfer settings, e.g. transfer compression and multipart message size. # The properties max-file-size and max-request-size define the maximum size of files # transferred to and from the repository. Setting them to -1 removes all limits. -server.compression.enabled: false -spring.servlet.multipart.max-file-size: 100MB -spring.servlet.multipart.max-request-size: 100MB - -# *Generic* Spring Management Endpoint Settings. By default, the health endpoint will be +server.compression.enabled=false +spring.servlet.multipart.max-file-size=100MB +spring.servlet.multipart.max-request-size=100MB + +# *Generic* Spring Management Endpoint Settings. By default, the health endpoint will be # enabled to apply service monitoring including detailed information. # Furthermore, all endpoints will be exposed to external access. If this is not desired, # just comment the property 'management.endpoints.web.exposure.include' in order to only # allow local access. -management.endpoint.health.access: unrestricted -management.endpoint.health.show-details: ALWAYS -management.endpoint.health.sensitive: false -management.endpoints.web.exposure.include: health, info +management.endpoint.health.access=unrestricted +management.endpoint.health.show-details=ALWAYS +management.endpoint.health.sensitive=false +management.endpoints.web.exposure.include=health, info + ############### ### Logging ### @@ -56,32 +60,34 @@ management.endpoints.web.exposure.include: health, info # overwhelming. #logging.level.root: ERROR #logging.level.edu.kit.datamanager.doip:TRACE -logging.level.edu.kit: WARN +logging.level.edu.kit=WARN #logging.level.org.springframework.transaction: TRACE -logging.level.org.springframework: WARN -logging.level.org.springframework.amqp: WARN +logging.level.org.springframework=WARN +logging.level.org.springframework.amqp=WARN #logging.level.com.zaxxer.hikari: ERROR -logging.level.edu.kit.datamanager.pit.cli: INFO +logging.level.edu.kit.datamanager.pit.cli=INFO + ###################### ### Authentication ### ###################### -# Enable/disable (default) authentication. If authentication is enabled, a separate +# Enable/disable (default) authentication. If authentication is enabled, a separate # Authentication Service should be used in order to obtain JSON Web Tokens holding # login information. The token has then to be provided within the Authentication header # of each HTTP request with a value of 'Bearer ' without quotes, replacing # be the token obtained from the authentication service. # A token needs a "username" in its payload. A minimal token therefore may look like this: # https://jwt.io/#debugger-io?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIifQ.pfZuRuxbj_izZlCnmotWHQuH00BJ35CbjpHILpuQU70 -repo.auth.enabled: false +repo.auth.enabled=false # The jwtSecret is the mutual secret between all trusted services. This means, that if # authentication is enabled, the jwtSecret used by the Authentication Service to sign # issued JWTokens must be the same as the jwtSecret of the repository in order to # be able to validate the signature. By default, the secret should be selected randomly # and with a sufficient length. -repo.auth.jwtSecret: vkfvoswsohwrxgjaxipuiyyjgubggzdaqrcuupbugxtnalhiegkppdgjgwxsmvdb +repo.auth.jwtSecret=vkfvoswsohwrxgjaxipuiyyjgubggzdaqrcuupbugxtnalhiegkppdgjgwxsmvdb + ############################### ### Keycloak Authentication ### @@ -94,9 +100,10 @@ spring.autoconfigure.exclude=org.keycloak.adapters.springboot.KeycloakAutoConfig ##keycloakjwt.connect-timeoutms=500 # optional ##keycloakjwt.read-timeoutms=500 # optional # -#keycloak.realm = myrealm -#keycloak.auth-server-url = http://localhost:8080/auth -#keycloak.resource = keycloak-angular +#keycloak.realm=myrealm +#keycloak.auth-server-url=http://localhost:8080/auth +#keycloak.resource=keycloak-angular + ############################################ ### Elastic Indexing and search endpoint ### @@ -104,13 +111,13 @@ spring.autoconfigure.exclude=org.keycloak.adapters.springboot.KeycloakAutoConfig ############################################ # enables search endpoint at /api/v1/search -repo.search.enabled: false -repo.search.index: * +repo.search.enabled=false +repo.search.index=* # only enable if endpoint is enabled: -management.health.elasticsearch.enabled: false +management.health.elasticsearch.enabled=false # TO BE REMOVED! -repo.search.url: http://localhost:9200 +repo.search.url=http://localhost:9200 # Soon will be: #spring.elasticsearch.uris=http://localhost:9200 #spring.elasticsearch.username=user @@ -121,6 +128,7 @@ repo.search.url: http://localhost:9200 # https://github.com/spring-cloud/spring-cloud-gateway/issues/3154 spring.cloud.gateway.proxy.sensitive=content-length + ################# ### Messaging ### ################# @@ -132,56 +140,86 @@ spring.cloud.gateway.proxy.sensitive=content-length # the all properties with 'binding' define from where messages are received, e.g. the # exchange aka. topic and the queue. The routingKeys are defining wich messages are # routed to the aforementioned queue. -repo.messaging.enabled: false +repo.messaging.enabled=false # enables report via health actuator. Only activate if messaging is enabled. -management.health.rabbit.enabled: false -repo.messaging.hostname: localhost -repo.messaging.port: 5672 -repo.messaging.sender.exchange: record_events +management.health.rabbit.enabled=false +repo.messaging.hostname=localhost +repo.messaging.port=5672 +repo.messaging.sender.exchange=record_events # The rate in milliseconds at which the repository itself will check for new messages. # E.g. if a resource has been created, the repository may has to perform additional -# ingest steps. Therefor, special handlers can be added which will be executed at the +# ingest steps. Therefore, special handlers can be added which will be executed at the # configured repo.schedule.rate if a new message has been received. -repo.schedule.rate:1000 +repo.schedule.rate=1000 + ####################################################### ##################### PIT Service ##################### ####################################################### + # Standard resolver for Handle PIDs. Should usually stay like this. -pit.pidsystem.handle.baseURI = https://hdl.handle.net/ +pit.pidsystem.handle.baseURI=https://hdl.handle.net/ ### Choosing and configuring the PID system ### # Available implementations: # - IN_MEMORY (default, sandboxed, non-permanent PIDs, for short testing / demonstration only), # - LOCAL (sandboxed, uses local database, no public PIDs!, for long term testing or special use-cases), # - HANDLE_PROTOCOL (recommended, for real FAIR Digital Objects), -pit.pidsystem.implementation = LOCAL +pit.pidsystem.implementation=LOCAL + # If you chose IN_MEMORY, no further configuration is required. # If you chose LOCAL, no further configuration is required. # If you chose HANDLE_PROTOCOL, you need to set up your prefix and its key/certificate: -#pit.pidsystem.handle-protocol.credentials.handleIdentifierPrefix = 21.T11981 -#pit.pidsystem.handle-protocol.credentials.userHandle = 21.T11981/USER01 -#pit.pidsystem.handle-protocol.credentials.privateKeyPath = test_prefix_data/21.T11981_USER01_300_privkey.bin +#pit.pidsystem.handle-protocol.credentials.handleIdentifierPrefix=21.T11981 +#pit.pidsystem.handle-protocol.credentials.userHandle=21.T11981/USER01 +#pit.pidsystem.handle-protocol.credentials.privateKeyPath=test_prefix_data/21.T11981_USER01_300_privkey.bin + # The handle system supports the redirection of web browsers to a URL. # If your records may have such a URL stored in an attribute, you can # list the attributes here. The first attribute to be found will have # its value copied to a handle specific attribute (with key "URL"), # enabling URL redirection. Only affects the handle system! # Obligation: Optional (option missing = empty list) -pit.pidsystem.handle-protocol.handleRedirectAttributes = {'21.T11148/b8457812905b83046284'} +pit.pidsystem.handle-protocol.handleRedirectAttributes={'21.T11148/b8457812905b83046284'} ### Base URL for the DTR used. ### -# Currently, we support the DTRs of GWDG/ePIC. Currently known instances: -# - http://dtr-test.pidconsortium.eu/, https://dtr-test.pidconsortium.net/ -# - http://dtr-pit.pidconsortium.eu/, http://dtr-pit.pidconsortium.net/ -# - http://typeregistry.org/ -pit.typeregistry.baseURI = https://dtr-test.pidconsortium.eu/ +# Currently, we support the DTRs of GWDG/ePIC. +pit.typeregistry.baseURI=https://typeapi.lab.pidconsortium.net + +# If the attribute(s) keys/types in your PID records are not being +# recognized as such, please contact us. +# As a workaround, add them to this list. +# pit.validation.profileKeys={} ### As this service is a RESTful serice without GUI, CSRF protection is not required. ### -pit.security.enable-csrf: false +pit.security.enable-csrf=false + ### You may define patterns here for services which are allowed for communication. (CORS) ### -pit.security.allowedOriginPattern: http*://localhost:[*] +pit.security.allowedOriginPattern=http*://localhost:[*] + +### Caching settings for validation ### +# The maximum number of entries in the cache. +# pit.typeregistry.cache.maxEntries:1000 + +# The time in minutes after which Entries will expire, starting from the +# last update. +# pit.typeregistry.cache.lifetimeMinutes:10 + +# Profiles may disallow additional attributes in the PID records. This +# option may be used to override this behavior for this instance. +# If set to false, it will behave as the profiles describe. +# If set to true, additional attributes will always be allowed. +pit.validation.alwaysAllowAdditionalAttributes=true + +### DANGEROUS OPTIONS! Please read carefully! ######################################## +# This will disable validation. It is only meant for testing and rare cases +# where a DTR may not be available or an external validator is being +# used. +# +# pit.validation.strategy=none-debug +### DANGEROUS OPTIONS! Please read carefully! ######################################## + ####################################################### #################### PID GENERATOR #################### @@ -190,38 +228,38 @@ pit.security.allowedOriginPattern: http*://localhost:[*] # The PID generator to use for the suffix. Possible values: # "uuid4": generates a UUID v4 (random) PID suffix. # "hex-chunks": generates hex-chunks. Each chunk is four characters long. Example: 1D6C-152C-C9E0-C136-1509 -pit.pidgeneration.mode = uuid4 +pit.pidgeneration.mode=uuid4 # A prefix for branding, in addition to the PID system prefix. # Structure: -# Example: branding-prefix = "my-project.", system-prefix = "21.T11981", suffix = "12345" -# => PID = "21.T11981/my-project.12345" -# -# pit.pidgeneration.branding-prefix = my-project. +# Example: branding-prefix="my-project.", system-prefix="21.T11981", suffix="12345" +# => PID="21.T11981/my-project.12345" +# pit.pidgeneration.branding-prefix=my-project. # Applies a casing on the PIDs after generation (see "mode" property). Possible values: # "lower": all characters are lower case # "upper": all characters are upper case # "unmodified": no casing is applied after generation. Result depends fully on the generator. -pit.pidgeneration.casing = lower +pit.pidgeneration.casing=lower # Affects chunk-based generation modes (see pid.pidgeneration.mode) only. # Defines the number of chunks the generator should generate for each PID. # Default: 4 -# pit.pidgeneration.num-chunks = 4 +# pit.pidgeneration.num-chunks=4 -### DANGEROUS OPTION! Please read carefully! ######################################## +### DANGEROUS OPTIONS! Please read carefully! ######################################## # Please keep this option as a last resort vor special use-cases # where you need total control about the PID suffix you want to create. # In addition to authentication, we recommend fully hide the Typed PID Maker behind # a gateway which will manage your custom PIDs. # NOTE! If you do not already include the configured prefix in the PID, it will be appended. # This means that you can not create PIDs with a suffix starting with the system prefix. -# Example: system prefix = "abc", suffix = abcdef -# => PID = "abc/def" (delimiter may depend on PID system) -# -# pit.pidgeneration.custom-client-pids-enabled = false -### DANGEROUS OPTION! Please read carefully! ######################################## +# Example: system prefix="abc", suffix=abcdef +# => PID="abc/def" (delimiter may depend on PID system) + +# pit.pidgeneration.custom-client-pids-enabled=false +### DANGEROUS OPTIONS! Please read carefully! ######################################## + ################################ ######## Database ############## @@ -239,18 +277,22 @@ pit.pidgeneration.casing = lower # The following properties can (and should) be set. # When to store PIDs in the local database ("known PIDs") -pit.storage.strategy: keep-resolved-and-modified +pit.storage.strategy=keep-resolved-and-modified + #pit.storage.strategy: keep-resolved # The driver determines the database system to start. Other drivers are untested, but may work. -spring.datasource.driver-class-name: org.h2.Driver +spring.datasource.driver-class-name=org.h2.Driver + # Next, please choose a location for the database file on your file system. # WARNING: If no url is being defined, an in-memory database is being used, # loosing all data on restart. # WARNING: Change the DB to be stored somewhere outside of /tmp! -spring.datasource.url: jdbc:h2:file:/tmp/database;MODE=LEGACY;NON_KEYWORDS=VALUE +spring.datasource.url=jdbc:h2:file:/tmp/database;MODE=LEGACY;NON_KEYWORDS=VALUE + # Credentials for the database: -spring.datasource.username: typid -spring.datasource.password: secure_me +spring.datasource.username=typid +spring.datasource.password=secure_me + # Do not change ddl-auto if you do not know what you are doing: # https://docs.spring.io/spring-boot/docs/1.1.0.M1/reference/html/howto-database-initialization.html -spring.jpa.hibernate.ddl-auto: update +spring.jpa.hibernate.ddl-auto=update diff --git a/config/application-docker.properties b/config/application-docker.properties index d8c92b87..e1688388 100644 --- a/config/application-docker.properties +++ b/config/application-docker.properties @@ -163,6 +163,7 @@ pit.pidsystem.implementation = LOCAL #pit.pidsystem.handle-protocol.credentials.handleIdentifierPrefix = 21.T11981 #pit.pidsystem.handle-protocol.credentials.userHandle = 21.T11981/USER01 #pit.pidsystem.handle-protocol.credentials.privateKeyPath = test_prefix_data/21.T11981_USER01_300_privkey.bin + # The handle system supports the redirection of web browsers to a URL. # If your records may have such a URL stored in an attribute, you can # list the attributes here. The first attribute to be found will have @@ -172,11 +173,11 @@ pit.pidsystem.implementation = LOCAL pit.pidsystem.handle-protocol.handleRedirectAttributes = {'21.T11148/b8457812905b83046284'} ### Base URL for the DTR used. ### -# Currently, we support the DTRs of GWDG/ePIC. Currently known instances: -# - http://dtr-test.pidconsortium.eu/, https://dtr-test.pidconsortium.net/ -# - http://dtr-pit.pidconsortium.eu/, http://dtr-pit.pidconsortium.net/ -# - http://typeregistry.org/ -pit.typeregistry.baseURI = https://dtr-test.pidconsortium.eu/ +# Currently, we support the DTRs of GWDG/ePIC. +pit.typeregistry.baseURI = https://typeapi.lab.pidconsortium.net +# If the attribute(s) keys/types in your PID records are not being recognized as such, please contact us. +# As a workaround, add them to this list: +pit.validation.profileKeys = {} ### As this service is a RESTful serice without GUI, CSRF protection is not required. ### pit.security.enable-csrf: false diff --git a/docker/README.md b/docker/README.md index 4c1c9042..5b211860 100644 --- a/docker/README.md +++ b/docker/README.md @@ -2,25 +2,45 @@ There are two images: -1. `Dockerfile-build-in-image` will build the image in the container. The result is still a clean image as it uses multi-stage-builds to only include what it really needs to run. This is good for a container release. -2. `Dockerfile-reuse-local-build` will reuse the build on your local machine. This is good for local development and reusing CI artifacts. +- `Dockerfile-build-in-image` + - Will build the image in the container. The result is still a clean image as it uses multi-stage-builds to only include what it really needs to run. + - Best for making a container release. + - Used by CI to create an image for publication. +- `Dockerfile-reuse-local-build` + - Will reuse the build on your local machine. + - Good for local development and reusing CI artifacts. + - Used in `test_docker.sh` and build/test CI. -The CI will use the first Dockerfile to build and provide images. -## Testing +## Purpose -The test script `docker_tests.sh` will: +For the recommended usage instructions, see the main readme file. The test script `docker_tests.sh` will: - build and run a container, named `typed-pid-maker-test` - - without parameters, it will use the `Dockerfile-reuse-local-build` definition. - - given an arbitrary argument (we recommend "release" or "build-in-image" for readability), it will use the `Dockerfile-build-in-image` definition. -- execute all tests in the `tests` subfolder +- if the script gets `benchmark` as its first parameter: + - execute benchmark tests repeatedly +- else (default): + - execute all tests in the `tests` subfolder - stop and delete the container -The idea behind these tests is to have basic tests from a very practical perspective (integration tests, component/service tests). The goal is to test the docker container, but also the standard configuration (application.properties) and the spring setup in general. Examples: +The idea behind the tests is to have basic tests from a very practical perspective (integration tests, component/service tests). The goal is to test the docker container, but also the standard configuration (application.properties) and the spring setup in general. Examples: -- test the creation of a PID: This makes sure that the request actually reaches the handler function. Such a test should exist for all endpoint with different security configurations. +- test the creation of a PID: This makes sure that the request actually reaches the handler function. Such a test should exist for all endpoints with different security configurations. - test if swagger page is reachable: This makes sure that the spring and openapi/swagger libraries work well together in the current version. - test if openAPI definition is reachable: same as above. -The goal is **not** to achieve full test coverage here. For this, we have unit tests and integration tests in `src/test`. \ No newline at end of file +The goal is **not** to achieve full test coverage here. For this, we have unit tests and integration tests in `src/test`. + +The benchmark mode is available to see the impact of new developments on the validation time needed. + +## Benchmark results + +Let's collect some results of the benchmarks: + +- Mac Studio with M1 Max, 32GB RAM, Sonoma 14.7.2 + - Commit: 8fe47fc6781ed08457cedc09d0c0efbccf7359c7 + - Executed files: 1000 + - Executed requests: 1001 (6.2/s) + - Succeeded files: 1000 (100.0%) + - Failed files: 0 (0.0%) + - Duration: 160903 ms \ No newline at end of file diff --git a/docker/test_docker.sh b/docker/test_docker.sh old mode 100644 new mode 100755 index 10f8f312..6121b547 --- a/docker/test_docker.sh +++ b/docker/test_docker.sh @@ -1,46 +1,77 @@ #!/usr/bin/env bash +# hurl is required +if ! command -v hurl &>/dev/null; then + echo "> hurl is required but it's not installed. Aborting." >&2 + exit 1 +fi + +if [ "$1" == "benchmark" ]; then + benchmark_mode=0 # 0 (true) for benchmarks + echo "> benchmark mode" +else + benchmark_mode=1 # 1 (false) for tests + echo "> test mode" +fi + # docker parameters tag=typed-pid-maker-test container=typid-test + # meta information for this script this=${BASH_SOURCE[0]} -echo "this script is at $this" +echo "> this script is at $this" docker_dir=$(dirname "$this") -echo "build docker image" +echo "> trigger local build" +"$docker_dir"/../gradlew build -x test || exit 1 + +echo "> build docker image, reusing local build" sleep .2 -# use "standalone" or "release" to build in the docker container -if [ "$1" ] -then - echo " > compiling in container: " - docker build --file $docker_dir/Dockerfile-build-in-image --tag $tag $docker_dir/.. || exit 1 -else - echo " > reusing local build: " - docker build --file $docker_dir/Dockerfile-reuse-local-build --tag $tag $docker_dir/.. || exit 1 -fi +docker build --file "$docker_dir/Dockerfile-reuse-local-build" \ + --tag "$tag" "$docker_dir/.." || exit 1 -echo -n "run container: " -docker run -p 8090:8090 --detach --name $container $tag +echo -n "> run container: " +docker run \ + --env pit.typeregistry.cache.maxEntries=0 \ + -p 8090:8090 \ + --detach \ + --name $container $tag + +echo "> Making sure the service is ready to accept requests" +# lets do something which will definitely not influence caches: +hurl --retry=20 --retry-interval=5000 \ + --test "$docker_dir"/tests/resolve-nonexisting.hurl || exit 1 +sleep 3 +echo "> Service is ready. Starting with actual tests." ##################################### -### tests ########################### +### run tests / benchmarks ################ ##################################### -hurl --retry=10 --retry-interval=10000 \ - --test "$docker_dir"/tests/*.hurl -failure=$? +echo "> benchmark mode: $benchmark_mode" +if [ "$benchmark_mode" -eq 0 ]; then + echo "> running benchmark" + hurl --retry=20 --retry-interval=5000 \ + --test --jobs 1 --repeat 1000 \ + "$docker_dir"/tests/benchmark-create_dryrun.hurl + failure=$? +else + echo "> running tests" + hurl --retry=20 --retry-interval=5000 \ + --test "$docker_dir"/tests/*.hurl + failure=$? +fi ##################################### -echo -n "stopping container ... " +echo -n "> stopping container ... " docker container stop $container -echo -n "removing container ... " +echo -n "> removing container ... " docker container rm $container -if [ $failure -eq 0 ] -then - echo "ALL TESTS SUCCESSFUL." - exit 0 +if [ $failure -eq 0 ]; then + echo "> ALL TESTS SUCCESSFUL." + exit 0 else - echo "TESTS FAILED (see output for details)" - exit 1 + echo "> TESTS FAILED (see output for details)" + exit 1 fi diff --git a/docker/tests/benchmark-create_dryrun.hurl b/docker/tests/benchmark-create_dryrun.hurl new file mode 100644 index 00000000..cb0b9643 --- /dev/null +++ b/docker/tests/benchmark-create_dryrun.hurl @@ -0,0 +1,65 @@ +# create/register a pid record +POST http://localhost:8090/api/v1/pit/pid/ +Content-Type: application/vnd.datamanager.pid.simple+json +Accept: application/vnd.datamanager.pid.simple+json +[QueryStringParams] +dryrun: true +{ + "record": [ + { + "key": "21.T11148/076759916209e5d62bd5", + "value": "21.T11148/b9b76f887845e32d29f7" + }, + { + "key": "21.T11148/1c699a5d1b4ad3ba4956", + "value": "21.T11148/ca9fd0b2414177b79ac2" + }, + { + "key": "21.T11148/a753134738da82809fc1", + "value": "21.T11148/a753134738da82809fc1" + }, + { + "key": "21.T11148/b8457812905b83046284", + "value": "https://hdl.handle.net/21.T11148/b8457812905b83046284" + }, + { + "key": "21.T11148/1a73af9e7ae00182733b", + "value": "https://orcid.org/0000-0001-6575-1022" + }, + { + "key": "21.T11148/aafd5fb4c7222e2d950a", + "value": "2020-10-21T00:00:00+02:00" + }, + { + "key": "21.T11969/a00985b98dac27bd32f8", + "value": "Book" + }, + { + "key": "21.T11148/2f314c8fe5fb6a0063a8", + "value": "{\"licenseURL\": \"https://www.gnu.org/licenses/agpl-3.0.en.html\"}" + }, + { + "key": "21.T11148/82e2503c49209e987740", + "value": "{\"md5sum\": \"2289159614f3e3b06fc436423c0dc398\"}" + }, + { + "key": "21.T11148/7fdada5846281ef5d461", + "value": "{\"locationPreview/Sample\": \"https://example.com/my/path/to/image.svg\"}" + }, + { + "key": "21.T11148/6ae999552a0d2dca14d6", + "value": "this-is-a-string" + }, + { + "key": "21.T11148/f3f0cbaa39fa9966b279", + "value": "{\"identifier\": \"this-is-a-string\"}" + }, + { + "key": "21.T11148/4fe7cde52629b61e3b82", + "value": "sandboxed/some-random-pid" + } + ] +} + +# on success, we get a 200 (no 201, because we're in dry-run mode) +HTTP 200 diff --git a/docker/tests/resolve-nonexisting.hurl b/docker/tests/resolve-nonexisting.hurl new file mode 100644 index 00000000..4438974e --- /dev/null +++ b/docker/tests/resolve-nonexisting.hurl @@ -0,0 +1,4 @@ +GET http://localhost:8090/api/v1/pit/pid/sandboxed/does-not-exist +# This test is used to determine if the service is up and running. + +HTTP 404 diff --git a/gradle.properties b/gradle.properties index 3e3f9f23..522f3a67 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,4 +14,4 @@ systemProp.jdk.tls.client.protocols="TLSv1,TLSv1.1,TLSv1.2" -version=2.2.1-rc3 \ No newline at end of file +version=3.3.0-rc10 diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 5857e71a..056ec5ee 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -1,264 +1,248 @@ -/* - * Copyright 2018 Karlsruhe Institute of Technology. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package edu.kit.datamanager.pit; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; -import com.google.common.cache.RemovalNotification; - -import edu.kit.datamanager.pit.cli.CliTaskBootstrap; -import edu.kit.datamanager.pit.cli.CliTaskWriteFile; -import edu.kit.datamanager.pit.cli.ICliTask; -import edu.kit.datamanager.pit.cli.PidSource; -import edu.kit.datamanager.pit.common.InvalidConfigException; -import edu.kit.datamanager.pit.configuration.ApplicationProperties; -import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; -import edu.kit.datamanager.pit.pitservice.ITypingService; -import edu.kit.datamanager.pit.pitservice.impl.TypingService; -import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; -import edu.kit.datamanager.pit.typeregistry.impl.TypeRegistry; -import edu.kit.datamanager.pit.web.converter.SimplePidRecordConverter; -import edu.kit.datamanager.security.filter.KeycloakJwtProperties; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.util.Objects; -import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; - -import org.apache.http.client.HttpClient; -import org.apache.http.impl.client.cache.CacheConfig; -import org.apache.http.impl.client.cache.CachingHttpClientBuilder; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.springframework.beans.factory.InjectionPoint; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Scope; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; -import org.springframework.scheduling.annotation.EnableScheduling; - -/** - * - * @author jejkal - */ -@SpringBootApplication -@EnableScheduling -@EntityScan({ "edu.kit.datamanager" }) -// Required for "DAO" objects to work, needed for messaging service and database -// mappings -@EnableJpaRepositories("edu.kit.datamanager") -// Detects services and components in datamanager dependencies (service-base and -// repo-core) -@ComponentScan({ "edu.kit.datamanager" }) -public class Application { - - private static final Logger LOG = LoggerFactory.getLogger(Application.class); - - protected static final String CMD_BOOTSTRAP = "bootstrap"; - protected static final String CMD_WRITE_FILE = "write-file"; - - protected static final String SOURCE_FROM_PREFIX = "all-pids-from-prefix"; - protected static final String SOURCE_KNOWN_PIDS = "known-pids"; - - protected static final String ERROR_COMMUNICATION = "Communication error: {}"; - protected static final String ERROR_CONFIGURATION = "Configuration error: {}"; - - - @Bean - @Scope("prototype") - public Logger logger(InjectionPoint injectionPoint) { - Class targetClass = injectionPoint.getMember().getDeclaringClass(); - return LoggerFactory.getLogger(targetClass.getCanonicalName()); - } - - @Bean - public ITypeRegistry typeRegistry() { - return new TypeRegistry(); - } - - @Bean - public ITypingService typingService(IIdentifierSystem identifierSystem, ApplicationProperties props) { - return new TypingService(identifierSystem, typeRegistry(), typeLoader(props)); - } - - @Bean(name = "OBJECT_MAPPER_BEAN") - public static ObjectMapper jsonObjectMapper() { - return Jackson2ObjectMapperBuilder.json() - .serializationInclusion(JsonInclude.Include.NON_EMPTY) // Don’t include null values - .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // ISODate - .modules(new JavaTimeModule()) - .build(); - } - - @Bean - public HttpClient httpClient() { - return CachingHttpClientBuilder - .create() - .setCacheConfig(cacheConfig()) - .build(); - } - - @Bean - public CacheConfig cacheConfig() { - return CacheConfig - .custom() - .setMaxObjectSize(500000) // 500KB - .setMaxCacheEntries(2000) - // Set this to false and a response with queryString - // will be cached when it is explicitly cacheable - // .setNeverCacheHTTP10ResponsesWithQueryString(false) - .build(); - } - - /** - * This loader is a cache, which will retrieve `TypeDefinition`s, if required. - * - * Therefore, it can be used instead of the ITypeRegistry implementations. - * Retrieve it using Autowire or from the application context. - * - * @param props the applications properties set by the administration at the - * start of this application. - * @return the cache - */ - @Bean - public LoadingCache typeLoader(ApplicationProperties props) { - int maximumsize = props.getMaximumSize(); - long expireafterwrite = props.getExpireAfterWrite(); - return CacheBuilder.newBuilder() - .maximumSize(maximumsize) - .expireAfterWrite(expireafterwrite, TimeUnit.MINUTES) - .removalListener((RemovalNotification rn) -> LOG.trace( - "Removing type definition located at {} from schema cache. Cause: {}", rn.getKey(), - rn.getCause())) - .build(new CacheLoader() { - @Override - public TypeDefinition load(String typeIdentifier) throws IOException, URISyntaxException { - LOG.trace("Loading type definition for identifier {} to cache.", typeIdentifier); - return typeRegistry().queryTypeDefinition(typeIdentifier); - } - }); - } - - @ConfigurationProperties("pit") - public ApplicationProperties applicationProperties() { - return new ApplicationProperties(); - } - - @Bean - // Reads keycloak related settings from properties.application. - public KeycloakJwtProperties properties() { - return new KeycloakJwtProperties(); - } - - @Bean - public HttpMessageConverter simplePidRecordConverter() { - return new SimplePidRecordConverter(); - } - - public static void main(String[] args) { - ConfigurableApplicationContext context = SpringApplication.run(Application.class, args); - System.out.println("Spring is running!"); - - final boolean cliArgsAmountValid = args != null && args.length != 0 && args.length >= 2; - - if (cliArgsAmountValid) { - ICliTask task = null; - Stream pidSource = null; - - if (Objects.equals(args[1], SOURCE_FROM_PREFIX)) { - try { - pidSource = PidSource.fromPrefix(context); - } catch (IOException e) { - e.printStackTrace(); - LOG.error(ERROR_COMMUNICATION, e.getMessage()); - exitApp(context, 1); - } - } else if (Objects.equals(args[1], SOURCE_KNOWN_PIDS)) { - pidSource = PidSource.fromKnown(context); - } - - if (Objects.equals(args[0], CMD_BOOTSTRAP)) { - task = new CliTaskBootstrap(context, pidSource); - } else if (Objects.equals(args[0], CMD_WRITE_FILE)) { - task = new CliTaskWriteFile(pidSource); - } - - try { - if (task != null && pidSource != null) { - // ---process task--- - if (task.process()) { - exitApp(context, 0); - } - } else { - printUsage(args); - exitApp(context, 1); - } - } catch (InvalidConfigException e) { - e.printStackTrace(); - LOG.error(ERROR_CONFIGURATION, e.getMessage()); - exitApp(context, 1); - } catch (IOException e) { - e.printStackTrace(); - LOG.error(ERROR_COMMUNICATION, e.getMessage()); - exitApp(context, 1); - } - } - } - - private static void printUsage(String[] args) { - String firstArg = args[0].replaceAll("[\r\n]",""); - String secondArg = args[1].replaceAll("[\r\n]",""); - LOG.error("Got commands: {} and {}", firstArg, secondArg); - LOG.error("CLI usage incorrect. Usage:"); - LOG.error("java -jar TypedPIDMaker.jar [ACTION] [SOURCE]"); - LOG.error("java -jar TypedPIDMaker.jar bootstrap all-pids-from-prefix"); - LOG.error("java -jar TypedPIDMaker.jar bootstrap known-pids"); - LOG.error("java -jar TypedPIDMaker.jar write-file all-pids-from-prefix"); - LOG.error("java -jar TypedPIDMaker.jar write-file known-pids"); - } - - private static void exitApp(ConfigurableApplicationContext context, int errCode) { - context.close(); - try { - Thread.sleep(2 * 1000L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - if (errCode != 0) { - LOG.error("Exited with error."); - } else { - LOG.info("Success"); - } - System.exit(errCode); - } - -} +/* + * Copyright 2018 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package edu.kit.datamanager.pit; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import edu.kit.datamanager.pit.cli.CliTaskBootstrap; +import edu.kit.datamanager.pit.cli.CliTaskWriteFile; +import edu.kit.datamanager.pit.cli.ICliTask; +import edu.kit.datamanager.pit.cli.PidSource; +import edu.kit.datamanager.pit.common.InvalidConfigException; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; +import edu.kit.datamanager.pit.pitservice.ITypingService; +import edu.kit.datamanager.pit.pitservice.impl.TypingService; +import edu.kit.datamanager.pit.resolver.Resolver; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; +import edu.kit.datamanager.pit.typeregistry.impl.TypeApi; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; +import edu.kit.datamanager.pit.web.converter.SimplePidRecordConverter; +import edu.kit.datamanager.security.filter.KeycloakJwtProperties; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Stream; + +import org.apache.http.client.HttpClient; +import org.apache.http.impl.client.cache.CacheConfig; +import org.apache.http.impl.client.cache.CachingHttpClientBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.InjectionPoint; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Scope; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +@EntityScan({ "edu.kit.datamanager" }) +// Required for "DAO" objects to work, needed for messaging service and database +// mappings +@EnableJpaRepositories("edu.kit.datamanager") +// Detects services and components in datamanager dependencies (service-base and +// repo-core) +@ComponentScan({ "edu.kit.datamanager" }) +public class Application { + + private static final Logger LOG = LoggerFactory.getLogger(Application.class); + + protected static final String CMD_BOOTSTRAP = "bootstrap"; + protected static final String CMD_WRITE_FILE = "write-file"; + + protected static final String SOURCE_FROM_PREFIX = "all-pids-from-prefix"; + protected static final String SOURCE_KNOWN_PIDS = "known-pids"; + + protected static final String ERROR_COMMUNICATION = "Communication error: {}"; + protected static final String ERROR_CONFIGURATION = "Configuration error: {}"; + + /** + * This is a threshold considered very long for a http request. + * Usually used in logging context + */ + public static final long LONG_HTTP_REQUEST_THRESHOLD = 400; + + @Bean + @Scope("prototype") + public Logger logger(InjectionPoint injectionPoint) { + Class targetClass = injectionPoint.getMember().getDeclaringClass(); + return LoggerFactory.getLogger(targetClass.getCanonicalName()); + } + + public static ExecutorService newExecutor() { + return Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory()); + } + + @Bean + public SchemaSetGenerator schemaSetGenerator(ApplicationProperties props) { + return new SchemaSetGenerator(props); + } + + @Bean + public ITypeRegistry typeRegistry(ApplicationProperties props, SchemaSetGenerator schemaSetGenerator) { + return new TypeApi(props, schemaSetGenerator); + } + + @Bean + public Resolver resolver(ITypingService identifierSystem) { + return new Resolver(identifierSystem); + } + + @Bean + public ITypingService typingService(IIdentifierSystem identifierSystem, ITypeRegistry typeRegistry) { + return new TypingService(identifierSystem, typeRegistry); + } + + @Bean(name = "OBJECT_MAPPER_BEAN") + public static ObjectMapper jsonObjectMapper() { + return Jackson2ObjectMapperBuilder.json() + .serializationInclusion(JsonInclude.Include.NON_EMPTY) // Don’t include null values + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // ISODate + .modules(new JavaTimeModule()) + .build(); + } + + @Bean + public HttpClient httpClient() { + return CachingHttpClientBuilder + .create() + .setCacheConfig(cacheConfig()) + .build(); + } + + @Bean + public CacheConfig cacheConfig() { + return CacheConfig + .custom() + .setMaxObjectSize(500000) // 500KB + .setMaxCacheEntries(2000) + // Set this to false and a response with queryString + // will be cached when it is explicitly cacheable + // .setNeverCacheHTTP10ResponsesWithQueryString(false) + .build(); + } + + @Bean + @ConfigurationProperties("pit") + public ApplicationProperties applicationProperties() { + return new ApplicationProperties(); + } + + @Bean + // Reads keycloak related settings from properties.application. + public KeycloakJwtProperties properties() { + return new KeycloakJwtProperties(); + } + + @Bean + public HttpMessageConverter simplePidRecordConverter() { + return new SimplePidRecordConverter(); + } + + public static void main(String[] args) { + ConfigurableApplicationContext context = SpringApplication.run(Application.class, args); + System.out.println("Spring is running!"); + + final boolean cliArgsAmountValid = args != null && args.length != 0 && args.length >= 2; + + if (cliArgsAmountValid) { + ICliTask task = null; + Stream pidSource = null; + + if (Objects.equals(args[1], SOURCE_FROM_PREFIX)) { + try { + pidSource = PidSource.fromPrefix(context); + } catch (IOException e) { + e.printStackTrace(); + LOG.error(ERROR_COMMUNICATION, e.getMessage()); + exitApp(context, 1); + } + } else if (Objects.equals(args[1], SOURCE_KNOWN_PIDS)) { + pidSource = PidSource.fromKnown(context); + } + + if (Objects.equals(args[0], CMD_BOOTSTRAP)) { + task = new CliTaskBootstrap(context, pidSource); + } else if (Objects.equals(args[0], CMD_WRITE_FILE)) { + task = new CliTaskWriteFile(pidSource); + } + + try { + if (task != null && pidSource != null) { + // ---process task--- + if (task.process()) { + exitApp(context, 0); + } + } else { + printUsage(args); + exitApp(context, 1); + } + } catch (InvalidConfigException e) { + e.printStackTrace(); + LOG.error(ERROR_CONFIGURATION, e.getMessage()); + exitApp(context, 1); + } catch (IOException e) { + e.printStackTrace(); + LOG.error(ERROR_COMMUNICATION, e.getMessage()); + exitApp(context, 1); + } + } + } + + private static void printUsage(String[] args) { + String firstArg = args[0].replaceAll("[\r\n]",""); + String secondArg = args[1].replaceAll("[\r\n]",""); + LOG.error("Got commands: {} and {}", firstArg, secondArg); + LOG.error("CLI usage incorrect. Usage:"); + LOG.error("java -jar TypedPIDMaker.jar [ACTION] [SOURCE]"); + LOG.error("java -jar TypedPIDMaker.jar bootstrap all-pids-from-prefix"); + LOG.error("java -jar TypedPIDMaker.jar bootstrap known-pids"); + LOG.error("java -jar TypedPIDMaker.jar write-file all-pids-from-prefix"); + LOG.error("java -jar TypedPIDMaker.jar write-file known-pids"); + } + + private static void exitApp(ConfigurableApplicationContext context, int errCode) { + context.close(); + try { + Thread.sleep(2 * 1000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (errCode != 0) { + LOG.error("Exited with error."); + } else { + LOG.info("Success"); + } + System.exit(errCode); + } + +} diff --git a/src/main/java/edu/kit/datamanager/pit/cli/CliTaskBootstrap.java b/src/main/java/edu/kit/datamanager/pit/cli/CliTaskBootstrap.java index 2abe28d8..1e0d7e23 100644 --- a/src/main/java/edu/kit/datamanager/pit/cli/CliTaskBootstrap.java +++ b/src/main/java/edu/kit/datamanager/pit/cli/CliTaskBootstrap.java @@ -77,7 +77,7 @@ public boolean process() throws IOException, InvalidConfigException { // store in Elasticsearch elastic.ifPresent(elastic -> { try { - PIDRecord rec = typingService.queryAllProperties(known.getPid()); + PIDRecord rec = typingService.queryPid(known.getPid()); LOG.info("Store PID {} in Elasticsearch.", known.getPid()); PidRecordElasticWrapper wrapper = new PidRecordElasticWrapper(rec, typingService.getOperations()); elastic.save(wrapper); diff --git a/src/main/java/edu/kit/datamanager/pit/common/ExternalServiceException.java b/src/main/java/edu/kit/datamanager/pit/common/ExternalServiceException.java index c2ade458..298c35ce 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/ExternalServiceException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/ExternalServiceException.java @@ -6,12 +6,12 @@ public class ExternalServiceException extends ResponseStatusException { - private static final String MESSAGE_TERM_SERVICE = "Service"; + private static final String MESSAGE_TERM_SERVICE = "Service "; private static final long serialVersionUID = 1L; private static final HttpStatus HTTP_STATUS = HttpStatus.SERVICE_UNAVAILABLE; public ExternalServiceException(String serviceName) { - super(HTTP_STATUS, "Service " + serviceName + " not available."); + super(HTTP_STATUS, MESSAGE_TERM_SERVICE + serviceName + " not available."); } public ExternalServiceException(String serviceName, Throwable e) { diff --git a/src/main/java/edu/kit/datamanager/pit/common/InvalidConfigException.java b/src/main/java/edu/kit/datamanager/pit/common/InvalidConfigException.java index 3bb32c42..acf4b5b6 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/InvalidConfigException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/InvalidConfigException.java @@ -11,4 +11,8 @@ public class InvalidConfigException extends ResponseStatusException { public InvalidConfigException(String message) { super(HTTP_STATUS, message); } + + public InvalidConfigException(String message, Throwable error) { + super(HTTP_STATUS, message, error); + } } diff --git a/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java b/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java index a72d4337..11c50d18 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java @@ -4,17 +4,33 @@ import org.springframework.http.HttpStatus; +import java.io.Serial; + /** * Indicates that a PID was given which could not be resolved to answer the * request properly. */ public class PidNotFoundException extends ResponseStatusException { - private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 3362829471655054621L; private static final HttpStatus HTTP_STATUS = HttpStatus.NOT_FOUND; + public static final String ID_NOT_FOUND_MSG = "Identifier with value %s not found."; + public static final String REASON_MSG = "%s Reason: %s"; public PidNotFoundException(String pid) { - super(HTTP_STATUS, "Identifier with value " + pid + " not found."); + super(HTTP_STATUS, ID_NOT_FOUND_MSG.formatted(pid)); + } + + public PidNotFoundException(String pid, String reason) { + super(HTTP_STATUS, REASON_MSG.formatted(ID_NOT_FOUND_MSG.formatted(pid), reason)); } + public PidNotFoundException(String pid, String reason, Throwable e) { + super(HTTP_STATUS, REASON_MSG.formatted(ID_NOT_FOUND_MSG.formatted(pid), reason), e); + } + + public PidNotFoundException(String pid, Throwable e) { + super(HTTP_STATUS, ID_NOT_FOUND_MSG.formatted(pid), e); + } } diff --git a/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java b/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java index 520cc748..9dca9d21 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java @@ -1,9 +1,24 @@ -package edu.kit.datamanager.pit.common; +/* + * Copyright (c) 2024 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ -import org.springframework.web.server.ResponseStatusException; -import org.springframework.http.HttpStatus; +package edu.kit.datamanager.pit.common; import edu.kit.datamanager.pit.domain.PIDRecord; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; /** * Indicates that a PID was given which could not be resolved to answer the @@ -11,24 +26,29 @@ */ public class RecordValidationException extends ResponseStatusException { - private static final String VALIDATION_OF_RECORD = "Validation of record "; - private static final long serialVersionUID = 1L; - private static final HttpStatus HTTP_STATUS = HttpStatus.BAD_REQUEST; + private static final String VALIDATION_OF_RECORD = "Validation of record "; + private static final long serialVersionUID = 1L; + private static final HttpStatus HTTP_STATUS = HttpStatus.BAD_REQUEST; + + // For cases in which the PID record should be appended to the error response. + private final transient PIDRecord pidRecord; - // For cases in which the PID record shold be appended to the error response. - private final transient PIDRecord pidRecord; + public RecordValidationException(PIDRecord pidRecord) { + super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed."); + this.pidRecord = pidRecord; + } - public RecordValidationException(PIDRecord pidRecord) { - super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed."); - this.pidRecord = pidRecord; - } + public RecordValidationException(PIDRecord pidRecord, String reason) { + super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason: " + reason); + this.pidRecord = pidRecord; + } - public RecordValidationException(PIDRecord pidRecord, String reason) { - super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason:\n" + reason); - this.pidRecord = pidRecord; - } + public RecordValidationException(PIDRecord pidRecord, String reason, Exception e) { + super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason: " + reason, e); + this.pidRecord = pidRecord; + } - public PIDRecord getPidRecord() { - return pidRecord; - } + public PIDRecord getPidRecord() { + return pidRecord; + } } diff --git a/src/main/java/edu/kit/datamanager/pit/common/TypeNotFoundException.java b/src/main/java/edu/kit/datamanager/pit/common/TypeNotFoundException.java index bf78369a..7d87b3b7 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/TypeNotFoundException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/TypeNotFoundException.java @@ -13,6 +13,6 @@ public class TypeNotFoundException extends ResponseStatusException { private static final HttpStatus HTTP_STATUS = HttpStatus.NOT_FOUND; public TypeNotFoundException(String pid) { - super(HTTP_STATUS, "The given PID " + pid + " is not a type in the configured registry."); + super(HTTP_STATUS, "The given PID \"" + pid + "\" is not a type in the configured registry."); } } diff --git a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java index 6b313b03..a9c422fa 100644 --- a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java +++ b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java @@ -21,9 +21,16 @@ import edu.kit.datamanager.pit.pitservice.impl.NoValidationStrategy; import java.net.URL; +import java.util.List; +import java.util.Set; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -45,6 +52,17 @@ @Configuration @Validated public class ApplicationProperties extends GenericApplicationProperties { + private static final Logger LOG = LoggerFactory.getLogger(ApplicationProperties.class); + + /** + * Internal default set of types which indicate that, when used as a key + * of an attribute, that the value of the attribute must be a profile. + * Used for profile detection in records. + */ + private static final Set KNOWN_PROFILE_KEYS = Set.of( + "21.T11148/076759916209e5d62bd5", + "21.T11969/bcc54a2a9ab5bf2a8f2c" + ); public enum IdentifierSystemImpl { IN_MEMORY, @@ -66,10 +84,10 @@ public enum ValidationStrategy { private ValidationStrategy validationStrategy = ValidationStrategy.EMBEDDED_STRICT; @Bean - public IValidationStrategy defaultValidationStrategy() { + public IValidationStrategy defaultValidationStrategy(ITypeRegistry typeRegistry) { IValidationStrategy defaultStrategy = new NoValidationStrategy(); if (this.validationStrategy == ValidationStrategy.EMBEDDED_STRICT) { - defaultStrategy = new EmbeddedStrictValidatorStrategy(); + defaultStrategy = new EmbeddedStrictValidatorStrategy(typeRegistry, this); } return defaultStrategy; } @@ -102,14 +120,35 @@ public boolean storesResolved() { private URL typeRegistryUri; @Value("${pit.typeregistry.cache.maxEntries:1000}") - private int maximumSize; + private int cacheMaxEntries; @Value("${pit.typeregistry.cache.lifetimeMinutes:10}") - private long expireAfterWrite; + private long cacheExpireAfterWriteLifetime; @Value("${pit.validation.profileKey:21.T11148/076759916209e5d62bd5}") + @Deprecated(forRemoval = true /*In Typed PID Maker 3.0.0*/) private String profileKey; + @Value("#{${pit.validation.profileKeys:{}}}") + @NotNull + protected List profileKeys = List.of(); + + @Getter + @Setter + @Value("${pit.validation.alwaysAllowAdditionalAttributes:true}") + private boolean validationAlwaysAllowAdditionalAttributes = true; + + public @NotNull Set getProfileKeys() { + Set allProfileKeys = new java.util.HashSet<>(Set.copyOf(KNOWN_PROFILE_KEYS)); + allProfileKeys.addAll(profileKeys); + allProfileKeys.add(this.getProfileKey()); + return allProfileKeys; + } + + public void setProfileKeys(@NotNull List profileKeys) { + this.profileKeys = profileKeys; + } + public IdentifierSystemImpl getIdentifierSystemImplementation() { return this.identifierSystemImplementation; } @@ -134,10 +173,12 @@ public void setTypeRegistryUri(URL typeRegistryUri) { this.typeRegistryUri = typeRegistryUri; } + @Deprecated(forRemoval = true) public String getProfileKey() { return this.profileKey; } + @Deprecated(forRemoval = true) public void setProfileKey(String profileKey) { this.profileKey = profileKey; } @@ -150,20 +191,23 @@ public void setValidationStrategy(ValidationStrategy strategy) { this.validationStrategy = strategy; } - public int getMaximumSize() { - return maximumSize; + public int getCacheMaxEntries() { + if (this.cacheMaxEntries <= 10) { + LOG.warn("Cache max entries is set to {} (low value)", this.cacheMaxEntries); + } + return this.cacheMaxEntries; } - public void setMaximumSize(int maximumSize) { - this.maximumSize = maximumSize; + public void setCacheMaxEntries(int cacheMaxEntries) { + this.cacheMaxEntries = cacheMaxEntries; } - public long getExpireAfterWrite() { - return expireAfterWrite; + public long getCacheExpireAfterWriteLifetime() { + return cacheExpireAfterWriteLifetime; } - public void setExpireAfterWrite(long expireAfterWrite) { - this.expireAfterWrite = expireAfterWrite; + public void setCacheExpireAfterWriteLifetime(long cacheExpireAfterWriteLifetime) { + this.cacheExpireAfterWriteLifetime = cacheExpireAfterWriteLifetime; } public StorageStrategy getStorageStrategy() { diff --git a/src/main/java/edu/kit/datamanager/pit/domain/Contributor.java b/src/main/java/edu/kit/datamanager/pit/domain/Contributor.java deleted file mode 100644 index 26e0c3cf..00000000 --- a/src/main/java/edu/kit/datamanager/pit/domain/Contributor.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package edu.kit.datamanager.pit.domain; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.Data; - -/** - * - * @author Torridity - */ -@Data -@JsonIgnoreProperties(ignoreUnknown = true) -public class Contributor { - - private String identifiedUsing; - private String name; - private String details; -} diff --git a/src/main/java/edu/kit/datamanager/pit/domain/ImmutableList.java b/src/main/java/edu/kit/datamanager/pit/domain/ImmutableList.java new file mode 100644 index 00000000..f2e2bd3a --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/domain/ImmutableList.java @@ -0,0 +1,10 @@ +package edu.kit.datamanager.pit.domain; + +import java.util.Collections; +import java.util.List; + +public record ImmutableList(List items) { + public ImmutableList { + items = Collections.unmodifiableList(items); + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java index dc040ca7..baa688ed 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java @@ -1,6 +1,7 @@ package edu.kit.datamanager.pit.domain; import java.io.IOException; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -8,16 +9,18 @@ import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; -import org.joda.time.DateTime; -import org.joda.time.format.DateTimeFormatter; -import org.joda.time.format.ISODateTimeFormat; - -import edu.kit.datamanager.pit.pitservice.ITypingService; +import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; +import org.apache.commons.lang3.stream.Streams; +import java.time.ZonedDateTime; /** * Simple operations on PID records. - * + *

* Caches results e.g. for type queries */ public class Operations { @@ -31,55 +34,55 @@ public class Operations { "21.T11148/397d831aa3a9d18eb52c" }; - private ITypingService typingService; + private final ITypeRegistry typeRegistry; + private final IIdentifierSystem identifierSystem; - public Operations(ITypingService typingService) { - this.typingService = typingService; + public Operations(ITypeRegistry typeRegistry, IIdentifierSystem identifierSystem) { + this.typeRegistry = typeRegistry; + this.identifierSystem = identifierSystem; } /** * Tries to get the date when a FAIR DO was created from a PID record. - * + *

* Strategy: * - try to get it from known "dateCreated" types - * - as a fallback, try to get it by its human readable name - * + * - as a fallback, try to get it by its human-readable name + *

* Semantic reasoning in some sense is planned but not yet supported. * * @param pidRecord the record to extract the information from. - * @return the date, if it could been extracted. + * @return the date, if it could have been extracted. * @throws IOException on IO errors regarding resolving types. */ public Optional findDateCreated(PIDRecord pidRecord) throws IOException { /* try known types */ List knownDateTypes = Arrays.asList(Operations.KNOWN_DATE_CREATED); Optional date = knownDateTypes - .stream() - .map(pidRecord::getPropertyValues) - .map(Arrays::asList) - .flatMap(List::stream) - .map(this::extractDate) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted(Comparator.comparingLong(Date::getTime)) - .findFirst(); + .stream() + .map(pidRecord::getPropertyValues) + .map(Arrays::asList) + .flatMap(List::stream) + .map(this::extractDate) + .filter(Optional::isPresent) + .map(Optional::get) + .min(Comparator.comparingLong(Date::getTime)); if (date.isPresent()) { return date; } - /* TODO try to find types extending or relating otherwise to known types - * (currently not supported by our TypeDefinition) */ - // we need to resolve types without streams to forward possible exceptions - Collection types = new ArrayList<>(); - for (String attributePid : pidRecord.getPropertyIdentifiers()) { - if (this.typingService.isIdentifierRegistered(attributePid)) { - TypeDefinition type = this.typingService.describeType(attributePid); - types.add(type); - } - } + Collection types = new ArrayList<>(); + List> futures = Streams.failableStream( + pidRecord.getPropertyIdentifiers().stream()) + .filter(this.identifierSystem::isPidRegistered) + .map(attributePid -> this.typeRegistry + .queryAttributeInfo(attributePid) + .thenAcceptAsync(types::add)) + .collect(Collectors.toList()); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); /* - * as a last fallback, try find types with human readable names containing + * as a last fallback, try to find types with human-readable names containing * "dateCreated" or "createdAt" or "creationDate". * * This can be removed as soon as we have some default FAIR DO types new type @@ -87,64 +90,60 @@ public Optional findDateCreated(PIDRecord pidRecord) throws IOException { * our known types, see above) */ return types - .stream() - .filter(type -> - type.getName().equals("dateCreated") - || type.getName().equals("createdAt") - || type.getName().equals("creationDate")) - .map(type -> pidRecord.getPropertyValues(type.getIdentifier())) - .map(Arrays::asList) - .flatMap(List::stream) - .map(this::extractDate) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted(Comparator.comparingLong(Date::getTime)) - .findFirst(); + .stream() + .filter(type -> + type.name().equalsIgnoreCase("dateCreated") + || type.name().equalsIgnoreCase("createdAt") + || type.name().equalsIgnoreCase("creationDate")) + .map(type -> pidRecord.getPropertyValues(type.pid())) + .map(Arrays::asList) + .flatMap(List::stream) + .map(this::extractDate) + .filter(Optional::isPresent) + .map(Optional::get) + .min(Comparator.comparingLong(Date::getTime)); } /** * Tries to get the date when a FAIR DO was modified from a PID record. - * + *

* Strategy: * - try to get it from known "dateModified" types - * - as a fallback, try to get it by its human readable name - * + * - as a fallback, try to get it by its human-readable name + *

* Semantic reasoning in some sense is planned but not yet supported. * * @param pidRecord the record to extract the information from. - * @return the date, if it could been extracted. + * @return the date, if it could have been extracted. * @throws IOException on IO errors regarding resolving types. */ public Optional findDateModified(PIDRecord pidRecord) throws IOException { /* try known types */ List knownDateTypes = Arrays.asList(Operations.KNOWN_DATE_MODIFIED); Optional date = knownDateTypes - .stream() - .map(pidRecord::getPropertyValues) - .map(Arrays::asList) - .flatMap(List::stream) - .map(this::extractDate) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted(Comparator.comparingLong(Date::getTime)) - .findFirst(); + .stream() + .map(pidRecord::getPropertyValues) + .map(Arrays::asList) + .flatMap(List::stream) + .map(this::extractDate) + .filter(Optional::isPresent) + .map(Optional::get) + .min(Comparator.comparingLong(Date::getTime)); if (date.isPresent()) { return date; } - /* TODO try to find types extending or relating otherwise to known types - * (currently not supported by our TypeDefinition) */ - // we need to resolve types without streams to forward possible exceptions - Collection types = new ArrayList<>(); - for (String attributePid : pidRecord.getPropertyIdentifiers()) { - if (this.typingService.isIdentifierRegistered(attributePid)) { - TypeDefinition type = this.typingService.describeType(attributePid); - types.add(type); - } - } + Collection types = new ArrayList<>(); + List> futures = Streams.failableStream(pidRecord.getPropertyIdentifiers().stream()) + .filter(this.identifierSystem::isPidRegistered) + .map(attributePid -> this.typeRegistry + .queryAttributeInfo(attributePid) + .thenAcceptAsync(types::add)) + .collect(Collectors.toList()); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); /* - * as a last fallback, try find types with human readable names containing + * as a last fallback, try to find types with human-readable names containing * "dateModified" or "lastModified" or "modificationDate". * * This can be removed as soon as we have some default FAIR DO types new type @@ -152,19 +151,18 @@ public Optional findDateModified(PIDRecord pidRecord) throws IOException { * our known types, see above) */ return types - .stream() - .filter(type -> - type.getName().equals("dateModified") - || type.getName().equals("lastModified") - || type.getName().equals("modificationDate")) - .map(type -> pidRecord.getPropertyValues(type.getIdentifier())) - .map(Arrays::asList) - .flatMap(List::stream) - .map(this::extractDate) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted(Comparator.comparingLong(Date::getTime)) - .findFirst(); + .stream() + .filter(type -> + type.name().equalsIgnoreCase("dateModified") + || type.name().equalsIgnoreCase("lastModified") + || type.name().equalsIgnoreCase("modificationDate")) + .map(type -> pidRecord.getPropertyValues(type.pid())) + .map(Arrays::asList) + .flatMap(List::stream) + .map(this::extractDate) + .filter(Optional::isPresent) + .map(Optional::get) + .min(Comparator.comparingLong(Date::getTime)); } /** @@ -174,10 +172,10 @@ public Optional findDateModified(PIDRecord pidRecord) throws IOException { * @return the extracted Date object. */ protected Optional extractDate(String dateString) { - DateTimeFormatter dateFormatter = ISODateTimeFormat.dateTime(); + DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME; try { - DateTime dateTime = dateFormatter.parseDateTime(dateString); - return Optional.of(dateTime.toDate()); + ZonedDateTime dateTime = ZonedDateTime.parse(dateString, dateFormatter); + return Optional.of(Date.from(dateTime.toInstant())); } catch (Exception e) { return Optional.empty(); } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java index 45629e31..4edac653 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java @@ -1,18 +1,27 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package edu.kit.datamanager.pit.domain; import com.fasterxml.jackson.annotation.JsonIgnore; - import edu.kit.datamanager.entities.EtagSupport; import edu.kit.datamanager.pit.pidsystem.impl.local.PidDatabaseObject; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; -import java.util.Set; /** * The internal representation for a PID record, offering methods to manipulate @@ -22,7 +31,7 @@ * communication or representation for the outside. In contrast, this is the * internal representation offering methods for manipulation. */ -public class PIDRecord implements EtagSupport { +public class PIDRecord implements EtagSupport, Cloneable { private String pid = ""; @@ -31,11 +40,12 @@ public class PIDRecord implements EtagSupport { /** * Creates an empty record without PID. */ - public PIDRecord() {} + public PIDRecord() { + } /** * Creates a record with the same content as the given representation. - * + * * @param dbo the given record representation. */ public PIDRecord(PidDatabaseObject dbo) { @@ -54,7 +64,7 @@ public PIDRecord(SimplePidRecord rec) { /** * Convenience setter / builder method. - * + * * @param pid the pid to set in this object. * @return this object (builder method). */ @@ -75,6 +85,15 @@ public Map> getEntries() { return entries; } + /** + * Sets the entries of this record. + * + * @param entries the entries to set. + */ + public void setEntries(Map> entries) { + this.entries = entries; + } + @JsonIgnore public Set getSimpleEntries() { return this.entries @@ -86,20 +105,16 @@ public Set getSimpleEntries() { .collect(Collectors.toSet()); } - public void setEntries(Map> entries) { - this.entries = entries; - } - public void addEntry(String propertyIdentifier, String propertyValue) { this.addEntry(propertyIdentifier, "", propertyValue); } /** * Adds a new key-name-value triplet. - * + * * @param propertyIdentifier the key/type PID. - * @param propertyName the human-readable name for the given key/type. - * @param propertyValue the value to this key/type. + * @param propertyName the human-readable name for the given key/type. + * @param propertyValue the value to this key/type. */ public void addEntry(String propertyIdentifier, String propertyName, String propertyValue) { if (propertyIdentifier.isEmpty()) { @@ -111,22 +126,22 @@ public void addEntry(String propertyIdentifier, String propertyName, String prop entry.setValue(propertyValue); this.entries - .computeIfAbsent(propertyIdentifier, key -> new ArrayList<>()) - .add(entry); + .computeIfAbsent(propertyIdentifier, key -> new ArrayList<>()) + .add(entry); } /** * Sets the name for a given key/type in all available pairs. - * + * * @param propertyIdentifier the key/type. - * @param name the new name. + * @param name the new name. */ @JsonIgnore public void setPropertyName(String propertyIdentifier, String name) { List propertyEntries = this.entries.get(propertyIdentifier); if (propertyEntries == null) { throw new IllegalArgumentException( - "Property identifier not listed in this record: " + propertyIdentifier); + "Property identifier not listed in this record: " + propertyIdentifier); } for (PIDRecordEntry entry : propertyEntries) { entry.setName(name); @@ -136,7 +151,7 @@ public void setPropertyName(String propertyIdentifier, String name) { /** * Check if there is a pair or triplet containing the given property (key/type) * is availeble in this record. - * + * * @param propertyIdentifier the key/type to search for. * @return true, if the property/key/type is present. */ @@ -157,27 +172,9 @@ public void removeAllValuesOf(String attribute) { this.entries.remove(attribute); } - /** - * Returns all missing mandatory attributes from the given Profile, which are not - * present in this record. - * - * @param profile the given Profile definition. - * @return all missing mandatory attributes. - */ - public Collection getMissingMandatoryTypesOf(TypeDefinition profile) { - Collection missing = new ArrayList<>(); - for (TypeDefinition td : profile.getSubTypes().values()) { - String typePid = td.getIdentifier(); - if (!td.isOptional() && !this.entries.containsKey(typePid)) { - missing.add(typePid); - } - } - return missing; - } - /** * Get all properties contained in this record. - * + * * @return al contained properties. */ @JsonIgnore @@ -199,7 +196,7 @@ public String getPropertyValue(String propertyIdentifier) { /** * Get all values of a given property. - * + * * @param propertyIdentifier the given property identifier. * @return all values of the given property. */ @@ -213,7 +210,7 @@ public String[] getPropertyValues(String propertyIdentifier) { for (PIDRecordEntry e : entry) { values.add(e.getValue()); } - return values.toArray(new String[] {}); + return values.toArray(new String[]{}); } @Override @@ -234,9 +231,15 @@ public int hashCode() { */ @Override public boolean equals(Object obj) { - if (this == obj) {return true;} - if (obj == null) {return false;} - if (getClass() != obj.getClass()) {return false;} + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } PIDRecord other = (PIDRecord) obj; boolean isThisPidEmpty = pid == null || pid.isBlank(); @@ -259,13 +262,32 @@ public String toString() { /** * Calculates an etag for a record. - * + * * @return an etag, which is independent of any order or duplicates in the - * entries. + * entries. */ @JsonIgnore @Override public String getEtag() { return Integer.toString(this.hashCode()); } + + @Override + public PIDRecord clone() { + try { + PIDRecord clone = (PIDRecord) super.clone(); + clone.pid = this.pid; + clone.entries = new HashMap<>(); + for (Map.Entry> entry : this.entries.entrySet()) { + List entryList = new ArrayList<>(); + for (PIDRecordEntry e : entry.getValue()) { + entryList.add(e.clone()); + } + clone.entries.put(entry.getKey(), entryList); + } + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java index e6a32cc0..bacd2884 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java @@ -1,24 +1,35 @@ /* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ + package edu.kit.datamanager.pit.domain; -import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; -/** - * - * @author Torridity - */ @Data -public class PIDRecordEntry { - +public class PIDRecordEntry implements Cloneable { private String key; private String name; private String value; - - @JsonIgnore - private TypeDefinition resolvedTypeDefinition; + + @Override + public PIDRecordEntry clone() { + try { + return (PIDRecordEntry) super.clone(); + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/ProvenanceInformation.java b/src/main/java/edu/kit/datamanager/pit/domain/ProvenanceInformation.java deleted file mode 100644 index 23a32811..00000000 --- a/src/main/java/edu/kit/datamanager/pit/domain/ProvenanceInformation.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package edu.kit.datamanager.pit.domain; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.Date; -import java.util.HashSet; -import java.util.Set; -import lombok.Data; - -/** - * - * @author Torridity - */ -@Data -@JsonIgnoreProperties(ignoreUnknown = true) -public class ProvenanceInformation { - - private Set contributors = new HashSet<>(); - private Date creationDate; - private Date lastModificationDate; - - public void addContributor(String identifiedBy, String name, String details) { - Contributor c = new Contributor(); - c.setIdentifiedUsing(identifiedBy); - c.setName(name); - c.setDetails(details); - contributors.add(c); - } - -} diff --git a/src/main/java/edu/kit/datamanager/pit/domain/TypeDefinition.java b/src/main/java/edu/kit/datamanager/pit/domain/TypeDefinition.java deleted file mode 100644 index b732db17..00000000 --- a/src/main/java/edu/kit/datamanager/pit/domain/TypeDefinition.java +++ /dev/null @@ -1,103 +0,0 @@ -/* SPDX-License-Identifier: Apache-2.0 */ - -package edu.kit.datamanager.pit.domain; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Set; -import lombok.Data; -import org.everit.json.schema.Schema; -import org.everit.json.schema.ValidationException; -import org.everit.json.schema.loader.SchemaLoader; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Representation of a type or profile definition in a data type registry. - * - * @author Thomas Jejkal - */ -@Data -@JsonIgnoreProperties(ignoreUnknown = true) -public class TypeDefinition { - - private static final Logger LOG = LoggerFactory.getLogger(TypeDefinition.class); - - private String name; - private String identifier; - private String description; - private boolean optional = false; - private boolean repeatable = false; - private String expression; - private String value; - private Schema jsonSchema; - - private ProvenanceInformation provenance; - @JsonProperty("properties") - private Map subTypes = new HashMap<>(); - - @JsonIgnore - private TypeDefinition resolvedTypeDefinition; - - @JsonIgnore - public Set getAllProperties() { - Set props = new HashSet<>(); - Set> entries = subTypes.entrySet(); - entries.forEach(entry -> props.add(entry.getKey())); - - return props; - } - - public void setSchema(String schema) { - if (schema == null) { - return; - } - - JSONObject jsonSchema = new JSONObject(schema); - this.jsonSchema = SchemaLoader.load(jsonSchema); - } - - /** - * Takes a value and validates it using this types JSON schema. - * - * @param document the value, usually taken from a PID record to be validated. - * @return true if the given value is valid accodting to this type. - */ - public boolean validate(String document) { - LOG.trace("Performing validate({}).", document); - if (jsonSchema != null) { - LOG.trace("Using schema-based validation."); - Object toValidate = document; - if (document.startsWith("{")) { - LOG.trace("Creating JSON object from provided value."); - toValidate = new JSONObject(document); - } - try { - LOG.trace("Validating provided value using type schema."); - jsonSchema.validate(toValidate); - LOG.trace("Validation successful."); - } catch (ValidationException ex) { - LOG.error("Validation failed.", ex); - return false; - } - } else { - LOG.trace("No schema available. Skipping validation."); - } - - return true; - } - - public boolean isOptional(String property) { - return subTypes.get(property).isOptional(); - } - - public void addSubType(TypeDefinition subType) { - subTypes.put(subType.getIdentifier(), subType); - } -} diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystem.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystem.java index 8adad1e1..a12eece6 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystem.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystem.java @@ -6,7 +6,6 @@ import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidgeneration.PidSuffix; import java.util.Collection; @@ -57,7 +56,7 @@ public default String appendPrefixIfAbsent(String pid) throws InvalidConfigExcep * @throws ExternalServiceException on commonication errors or errors on other * services. */ - public boolean isIdentifierRegistered(String pid) throws ExternalServiceException; + public boolean isPidRegistered(String pid) throws ExternalServiceException; /** * Checks whether the given PID is already registered. @@ -73,9 +72,9 @@ public default String appendPrefixIfAbsent(String pid) throws InvalidConfigExcep * @throws InvalidConfigException if there is no prefix configured to append to * the suffix. */ - public default boolean isIdentifierRegistered(PidSuffix suffix) throws ExternalServiceException, InvalidConfigException { + public default boolean isPidRegistered(PidSuffix suffix) throws ExternalServiceException, InvalidConfigException { String prefix = getPrefix().orElseThrow(() -> new InvalidConfigException("This system cannot create PIDs.")); - return isIdentifierRegistered(suffix.getWithPrefix(prefix)); + return isPidRegistered(suffix.getWithPrefix(prefix)); } /** @@ -89,27 +88,14 @@ public default boolean isIdentifierRegistered(PidSuffix suffix) throws ExternalS * @throws ExternalServiceException on commonication errors or errors on other * services. */ - public PIDRecord queryAllProperties(String pid) throws PidNotFoundException, ExternalServiceException; - - /** - * Queries a single property from the given PID. - * - * @param pid the PID to query from. - * @param typeDefinition the type to query. - * @return the property value or null if there is no property of given name - * defined in this PID record. - * @throws PidNotFoundException if PID is not registered. - * @throws ExternalServiceException if an error occured in communication with - * other services. - */ - public String queryProperty(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException; + public PIDRecord queryPid(String pid) throws PidNotFoundException, ExternalServiceException; /** * Registers a new PID with given property values. The method takes the PID from * the record and treats it as a suffix. * * The method must process the given PID using the - * {@link #registerPID(PIDRecord)} method. + * {@link #registerPid(PIDRecord)} method. * * @param pidRecord contains the initial PID record. * @return the PID that was assigned to the record. @@ -118,7 +104,7 @@ public default boolean isIdentifierRegistered(PidSuffix suffix) throws ExternalS * other services. * @throws RecordValidationException if record validation errors occurred. */ - public default String registerPID(final PIDRecord pidRecord) throws PidAlreadyExistsException, ExternalServiceException, RecordValidationException { + public default String registerPid(final PIDRecord pidRecord) throws PidAlreadyExistsException, ExternalServiceException, RecordValidationException { if (pidRecord.getPid() == null) { throw new RecordValidationException(pidRecord, "PID must not be null."); } @@ -133,7 +119,7 @@ public default String registerPID(final PIDRecord pidRecord) throws PidAlreadyEx /** * Registers the given record with its given PID, without applying any checks. - * Recommended to use {@link #registerPID(PIDRecord)} instead. + * Recommended to use {@link #registerPid(PIDRecord)} instead. * * As an implementor, you can assume the PID to be not null, valid, * non-registered, and prefixed. @@ -157,26 +143,7 @@ public default String registerPID(final PIDRecord pidRecord) throws PidAlreadyEx * other services. * @throws RecordValidationException if record validation errors occurred. */ - public boolean updatePID(PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException; - - /** - * Queries all properties of a given type available from the given PID. If - * optional properties are present, they will be returned as well. If there are - * mandatory properties missing (i.e. the record of the given PID does not fully - * conform to the type), the method will NOT fail but simply return only those - * properties that are present. - * - * @param pid the PID to query the type from. - * @param typeDefinition the type to query. - * @return a PID information record with property identifiers mapping to values. - * The property names will not be available (empty Strings). Contains - * all property values present in the record of the given PID that are - * also specified by the type (mandatory or optional). - * @throws PidNotFoundException if the pid is not registered. - * @throws ExternalServiceException if an error occured in communication with - * other services. - */ - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException; + public boolean updatePid(PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException; /** * Remove the given PID. @@ -187,7 +154,7 @@ public default String registerPID(final PIDRecord pidRecord) throws PidAlreadyEx * @param pid the PID to delete. * @return true if the identifier was deleted, false if it did not exist. */ - public boolean deletePID(String pid) throws ExternalServiceException; + public boolean deletePid(String pid) throws ExternalServiceException; /** * Returns all PIDs which are registered for the configured prefix. diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystem.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystem.java index 664b8aaa..87e180f4 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystem.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystem.java @@ -4,7 +4,6 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.stream.Collectors; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.InvalidConfigException; @@ -13,7 +12,6 @@ import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; import org.slf4j.Logger; @@ -47,23 +45,15 @@ public Optional getPrefix() { } @Override - public boolean isIdentifierRegistered(String pid) throws ExternalServiceException { + public boolean isPidRegistered(String pid) throws ExternalServiceException { return this.records.containsKey(pid); } @Override - public PIDRecord queryAllProperties(String pid) throws PidNotFoundException, ExternalServiceException { - PIDRecord pidRecord = this.records.get(pid); - if (pidRecord == null) { return null; } - return pidRecord; - } - - @Override - public String queryProperty(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { + public PIDRecord queryPid(String pid) throws PidNotFoundException, ExternalServiceException { PIDRecord pidRecord = this.records.get(pid); if (pidRecord == null) { throw new PidNotFoundException(pid); } - if (!pidRecord.hasProperty(typeDefinition.getIdentifier())) { return null; } - return pidRecord.getPropertyValue(typeDefinition.getIdentifier()); + return pidRecord; } @Override @@ -74,7 +64,7 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE } @Override - public boolean updatePID(PIDRecord record) throws PidNotFoundException, ExternalServiceException, RecordValidationException { + public boolean updatePid(PIDRecord record) throws PidNotFoundException, ExternalServiceException, RecordValidationException { if (this.records.containsKey(record.getPid())) { this.records.put(record.getPid(), record); return true; @@ -83,25 +73,7 @@ public boolean updatePID(PIDRecord record) throws PidNotFoundException, External } @Override - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - PIDRecord allProps = this.queryAllProperties(pid); - if (allProps == null) {return null;} - // only return properties listed in the type def - Set typeProps = typeDefinition.getAllProperties(); - PIDRecord result = new PIDRecord(); - for (String propID : allProps.getPropertyIdentifiers()) { - if (typeProps.contains(propID)) { - String[] values = allProps.getPropertyValues(propID); - for (String value : values) { - result.addEntry(propID, "", value); - } - } - } - return result; - } - - @Override - public boolean deletePID(String pid) { + public boolean deletePid(String pid) { throw new UnsupportedOperationException("Deleting PIDs is against the P in PID."); } diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleBehavior.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleBehavior.java new file mode 100644 index 00000000..59ec8055 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleBehavior.java @@ -0,0 +1,122 @@ +package edu.kit.datamanager.pit.pidsystem.impl.handle; + +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.domain.PIDRecordEntry; +import net.handle.hdllib.Common; +import net.handle.hdllib.HandleValue; + +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * This class defines helper functions and constants which define special + * behavior in the Typed PID Maker context. + *

+ * This has the purpose of reuse, e.g. in the resolver module, but also + * separating the authentication and PID logic from certain behavior aspects. + *

+ * In later aspects, this may be extended to implement an interface and use the + * strategy pattern in order to make the behavior configurable. + */ +public class HandleBehavior { + + /** + * A list of type codes which are considered "internal" or "handle-native" + * and should not be exposed. Use `isHandleInternalValue` to check if a + * value should be filtered out. + */ + private static final byte[][][] BLACKLIST_NONTYPE_LISTS = { + Common.SITE_INFO_AND_SERVICE_HANDLE_INCL_PREFIX_TYPES, + Common.DERIVED_PREFIX_SITE_AND_SERVICE_HANDLE_TYPES, + Common.SERVICE_HANDLE_TYPES, + Common.LOCATION_AND_ADMIN_TYPES, + Common.SECRET_KEY_TYPES, + Common.PUBLIC_KEY_TYPES, + // Common.STD_TYPES, // not using because of URL and EMAIL + { + // URL and EMAIL might contain valuable information and can be considered + // non-technical. + // Common.STD_TYPE_URL, + // Common.STD_TYPE_EMAIL, + Common.STD_TYPE_HSADMIN, + Common.STD_TYPE_HSALIAS, + Common.STD_TYPE_HSSITE, + Common.STD_TYPE_HSSITE6, + Common.STD_TYPE_HSSERV, + Common.STD_TYPE_HSSECKEY, + Common.STD_TYPE_HSPUBKEY, + Common.STD_TYPE_HSVALLIST, + } + }; + + /** + * This class is not meant to be instantiated. + */ + private HandleBehavior() {} + + /** + * Checks if a given value is considered an "internal" or "handle-native" value. + *

+ * This may be used to filter out administrative information from a PID record. + * + * @param v the value to check. + * @return true, if the value is considered "handle-native". + */ + public static boolean isHandleInternalValue(HandleValue v) { + boolean isInternalValue = false; + for (byte[][] typeList : BLACKLIST_NONTYPE_LISTS) { + for (byte[] typeCode : typeList) { + isInternalValue = isInternalValue || Arrays.equals(v.getType(), typeCode); + } + } + return isInternalValue; + } + + /** + * Convert a `PIDRecord` instance to an array of `HandleValue`s. It is the + * inverse method to `pidRecordFrom`. + * + * @param pidRecord the record containing values to convert / extract. + * @param toMerge an optional list to merge the result with. + * @return HandleValues containing the same key-value pairs as the given record, + * but e.g. without the name. + */ + public static ArrayList handleValuesFrom( + final PIDRecord pidRecord, + final Optional> toMerge) + { + ArrayList skippingIndices = new ArrayList<>(); + ArrayList result = new ArrayList<>(); + if (toMerge.isPresent()) { + for (HandleValue v : toMerge.get()) { + result.add(v); + skippingIndices.add(v.getIndex()); + } + } + HandleIndex index = new HandleIndex().skipping(skippingIndices); + Map> entries = pidRecord.getEntries(); + + for (Map.Entry> entry : entries.entrySet()) { + for (PIDRecordEntry val : entry.getValue()) { + String key = val.getKey(); + HandleValue hv = new HandleValue(); + int i = index.nextIndex(); + hv.setIndex(i); + hv.setType(key.getBytes(StandardCharsets.UTF_8)); + hv.setData(val.getValue().getBytes(StandardCharsets.UTF_8)); + result.add(hv); + } + } + assert result.size() >= pidRecord.getEntries().size(); + return result; + } + + public static PIDRecord recordFrom(final Collection values) { + PIDRecord record = new PIDRecord(); + values.forEach(v -> record.addEntry( + v.getTypeAsString(), + v.getDataAsString()) + ); + return record; + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiff.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiff.java new file mode 100644 index 00000000..4d1a2e37 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiff.java @@ -0,0 +1,82 @@ +package edu.kit.datamanager.pit.pidsystem.impl.handle; + +import edu.kit.datamanager.pit.common.PidUpdateException; +import net.handle.hdllib.HandleValue; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +/** + * Given two Value Maps, it splits the values in those which have been added, + * updated or removed. + * Using this lists, an update can be applied to the old record, to bring it to + * the state of the new record. + */ +class HandleDiff { + private final Collection toAdd = new ArrayList<>(); + private final Collection toUpdate = new ArrayList<>(); + private final Collection toRemove = new ArrayList<>(); + + HandleDiff( + final Map recordOld, + final Map recordNew + ) throws PidUpdateException { + for (Map.Entry old : recordOld.entrySet()) { + boolean wasRemoved = !recordNew.containsKey(old.getKey()); + if (wasRemoved) { + // if a row in the record is not available anymore, we need to delete it + toRemove.add(old.getValue()); + } else { + // otherwise, we should go and update it. + // we could also check for equality, but this is the safe and easy way. + // (the handlevalue classes can be complicated and we'd have to check their + // equality implementation) + toUpdate.add(recordNew.get(old.getKey())); + } + } + for (Map.Entry e : recordNew.entrySet()) { + boolean isNew = !recordOld.containsKey(e.getKey()); + if (isNew) { + // if there is a record which is not in the oldRecord, we need to add it. + toAdd.add(e.getValue()); + } + } + + // runtime testing to avoid messing up record states. + String exceptionMsg = "DIFF NOT VALID. Type: %s. Value: %s"; + for (HandleValue v : toRemove) { + boolean valid = recordOld.containsValue(v) && !recordNew.containsKey(v.getIndex()); + if (!valid) { + String message = String.format(exceptionMsg, "Remove", v.toString()); + throw new PidUpdateException(message); + } + } + for (HandleValue v : toAdd) { + boolean valid = !recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v); + if (!valid) { + String message = String.format(exceptionMsg, "Add", v); + throw new PidUpdateException(message); + } + } + for (HandleValue v : toUpdate) { + boolean valid = recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v); + if (!valid) { + String message = String.format(exceptionMsg, "Update", v); + throw new PidUpdateException(message); + } + } + } + + public HandleValue[] added() { + return this.toAdd.toArray(new HandleValue[] {}); + } + + public HandleValue[] updated() { + return this.toUpdate.toArray(new HandleValue[] {}); + } + + public HandleValue[] removed() { + return this.toRemove.toArray(new HandleValue[] {}); + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndex.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndex.java new file mode 100644 index 00000000..8349dad3 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndex.java @@ -0,0 +1,30 @@ +package edu.kit.datamanager.pit.pidsystem.impl.handle; + +import java.util.ArrayList; +import java.util.List; + +/** + * This class allows iterating over handle indices, skipping administrative ones. + */ +class HandleIndex { + // handle record indices start at 1 + private int index = 1; + private List skipping = new ArrayList<>(); + + public int nextIndex() { + int result = index; + do { + index += 1; + } while (index == this.getHsAdminIndex() || skipping.contains(index)); + return result; + } + + public HandleIndex skipping(List skipThose) { + this.skipping = skipThose; + return this; + } + + public int getHsAdminIndex() { + return 100; + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java similarity index 55% rename from src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java rename to src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java index 2af8acea..3e21f14b 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java @@ -1,17 +1,13 @@ -package edu.kit.datamanager.pit.pidsystem.impl; +package edu.kit.datamanager.pit.pidsystem.impl.handle; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.security.PrivateKey; import java.security.spec.InvalidKeySpecException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; -import java.util.Map.Entry; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -29,18 +25,14 @@ import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.common.PidAlreadyExistsException; import edu.kit.datamanager.pit.common.PidNotFoundException; -import edu.kit.datamanager.pit.common.PidUpdateException; import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.configuration.HandleCredentials; import edu.kit.datamanager.pit.configuration.HandleProtocolProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.PIDRecordEntry; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; import net.handle.api.HSAdapter; import net.handle.api.HSAdapterFactory; import net.handle.apps.batch.BatchUtil; -import net.handle.hdllib.Common; import net.handle.hdllib.HandleException; import net.handle.hdllib.HandleResolver; import net.handle.hdllib.HandleValue; @@ -58,35 +50,11 @@ public class HandleProtocolAdapter implements IIdentifierSystem { private static final Logger LOG = LoggerFactory.getLogger(HandleProtocolAdapter.class); - private static final byte[][][] BLACKLIST_NONTYPE_LISTS = { - Common.SITE_INFO_AND_SERVICE_HANDLE_INCL_PREFIX_TYPES, - Common.DERIVED_PREFIX_SITE_AND_SERVICE_HANDLE_TYPES, - Common.SERVICE_HANDLE_TYPES, - Common.LOCATION_AND_ADMIN_TYPES, - Common.SECRET_KEY_TYPES, - Common.PUBLIC_KEY_TYPES, - // Common.STD_TYPES, // not using because of URL and EMAIL - { - // URL and EMAIL might contain valuable information and can be considered - // non-technical. - // Common.STD_TYPE_URL, - // Common.STD_TYPE_EMAIL, - Common.STD_TYPE_HSADMIN, - Common.STD_TYPE_HSALIAS, - Common.STD_TYPE_HSSITE, - Common.STD_TYPE_HSSITE6, - Common.STD_TYPE_HSSERV, - Common.STD_TYPE_HSSECKEY, - Common.STD_TYPE_HSPUBKEY, - Common.STD_TYPE_HSVALLIST, - } - }; - private static final String SERVICE_NAME_HANDLE = "Handle System"; // Properties specific to this adapter. @Autowired - private HandleProtocolProperties props; + final private HandleProtocolProperties props; // Handle Protocol implementation private HSAdapter client; // indicates if the adapter can modify and create PIDs or just resolve them. @@ -101,7 +69,7 @@ public HandleProtocolAdapter(HandleProtocolProperties props) { /** * Initializes internal classes. - * We use this methos with the @PostConstruct annotation to run it + * We use this method with the @PostConstruct annotation to run it * after the constructor and after springs autowiring is done properly * to make sure that all properties are already autowired. * @@ -131,11 +99,10 @@ public void init() throws InvalidConfigException, HandleException, IOException { privateKey, passphrase // "use null for unencrypted keys" ); - HandleIndex indexManager = new HandleIndex(); this.adminValue = this.client.createAdminValue( props.getCredentials().getUserHandle(), props.getCredentials().getPrivateKeyIndex(), - indexManager.getHsAdminIndex()); + new HandleIndex().getHsAdminIndex()); } } @@ -149,8 +116,8 @@ public Optional getPrefix() { } @Override - public boolean isIdentifierRegistered(final String pid) throws ExternalServiceException { - HandleValue[] recordProperties = null; + public boolean isPidRegistered(final String pid) throws ExternalServiceException { + HandleValue[] recordProperties; try { recordProperties = this.client.resolveHandle(pid, null, null); } catch (HandleException e) { @@ -164,47 +131,26 @@ public boolean isIdentifierRegistered(final String pid) throws ExternalServiceEx } @Override - public PIDRecord queryAllProperties(final String pid) throws PidNotFoundException, ExternalServiceException { + public PIDRecord queryPid(final String pid) throws PidNotFoundException, ExternalServiceException { Collection allValues = this.queryAllHandleValues(pid); if (allValues.isEmpty()) { return null; } Collection recordProperties = Streams.failableStream(allValues.stream()) - .filter(value -> !this.isHandleInternalValue(value)) + .filter(value -> !HandleBehavior.isHandleInternalValue(value)) .collect(Collectors.toList()); - return this.pidRecordFrom(recordProperties).withPID(pid); + return HandleBehavior.recordFrom(recordProperties).withPID(pid); } @NotNull protected Collection queryAllHandleValues(final String pid) throws PidNotFoundException, ExternalServiceException { try { HandleValue[] values = this.client.resolveHandle(pid, null, null); - return Stream - .of(values) + return Stream.of(values) .collect(Collectors.toCollection(ArrayList::new)); } catch (HandleException e) { if (e.getCode() == HandleException.HANDLE_DOES_NOT_EXIST) { - return new ArrayList<>(); - } else { - throw new ExternalServiceException(SERVICE_NAME_HANDLE, e); - } - } - } - - @Override - public String queryProperty(final String pid, final TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - String[] typeArray = { typeDefinition.getIdentifier() }; - try { - // TODO we assume here that the property only exists once, which will not be - // true in every case. - // The interface likely should be adjusted so we can return all types and do not - // need to return a String. - return this.client.resolveHandle(pid, typeArray, null)[0].getDataAsString(); - } catch (HandleException e) { - if (e.getCode() == HandleException.INVALID_VALUE) { - return null; - } else if (e.getCode() == HandleException.HANDLE_DOES_NOT_EXIST) { - throw new PidNotFoundException(pid); + throw new PidNotFoundException(pid, e); } else { throw new ExternalServiceException(SERVICE_NAME_HANDLE, e); } @@ -221,7 +167,7 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE for (RecordModifier modifier : this.props.getConfiguredModifiers()) { preparedRecord = modifier.apply(preparedRecord); } - ArrayList futurePairs = this.handleValuesFrom(preparedRecord, Optional.of(admin)); + ArrayList futurePairs = HandleBehavior.handleValuesFrom(preparedRecord, Optional.of(admin)); HandleValue[] futurePairsArray = futurePairs.toArray(new HandleValue[] {}); @@ -239,7 +185,7 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE } @Override - public boolean updatePID(final PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException { + public boolean updatePid(final PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException { if (!this.isValidPID(pidRecord.getPid())) { return false; } @@ -271,11 +217,11 @@ public boolean updatePID(final PIDRecord pidRecord) throws PidNotFoundException, .collect(Collectors.toMap(HandleValue::getIndex, v -> v)); // 1) List valuesToKeep = oldHandleValues.stream() - .filter(this::isHandleInternalValue) + .filter(HandleBehavior::isHandleInternalValue) .collect(Collectors.toList()); // 2) Merge requested record and things we want to keep. - Map recordNew = handleValuesFrom(preparedRecord, Optional.of(valuesToKeep)) + Map recordNew = HandleBehavior.handleValuesFrom(preparedRecord, Optional.of(valuesToKeep)) .stream() .collect(Collectors.toMap(HandleValue::getIndex, v -> v)); @@ -305,27 +251,7 @@ public boolean updatePID(final PIDRecord pidRecord) throws PidNotFoundException, } @Override - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - PIDRecord allProps = queryAllProperties(pid); - if (allProps == null) { - return null; - } - // only return properties listed in the type definition - Set typeProps = typeDefinition.getAllProperties(); - PIDRecord result = new PIDRecord(); - for (String propID : allProps.getPropertyIdentifiers()) { - if (typeProps.contains(propID)) { - String[] values = allProps.getPropertyValues(propID); - for (String value : values) { - result.addEntry(propID, "", value); - } - } - } - return result; - } - - @Override - public boolean deletePID(final String pid) throws ExternalServiceException { + public boolean deletePid(final String pid) throws ExternalServiceException { try { this.client.deleteHandle(pid); } catch (HandleException e) { @@ -396,88 +322,6 @@ public Collection resolveAllPidsOfPrefix() throws ExternalServiceExcepti } } - /** - * Avoids an extra constructor in `PIDRecord`. Instead, - * keep such details stored in the PID service implementation. - * - * @param values HandleValue collection (ordering recommended) - * that shall be converted into a PIDRecord. - * @return a PID record with values copied from values. - */ - protected PIDRecord pidRecordFrom(final Collection values) { - PIDRecord result = new PIDRecord(); - for (HandleValue v : values) { - // TODO In future, the type could be resolved to store the human readable name - // here. - result.addEntry(v.getTypeAsString(), "", v.getDataAsString()); - } - return result; - } - - /** - * Convert a `PIDRecord` instance to an array of `HandleValue`s. It is the - * inverse method to `pidRecordFrom`. - * - * @param pidRecord the record containing values to convert / extract. - * @param toMerge an optional list to merge the result with. - * @return HandleValues containing the same key-value pairs as the given record, - * but e.g. without the name. - */ - protected ArrayList handleValuesFrom( - final PIDRecord pidRecord, - final Optional> toMerge) - { - ArrayList skippingIndices = new ArrayList<>(); - ArrayList result = new ArrayList<>(); - if (toMerge.isPresent()) { - for (HandleValue v : toMerge.get()) { - result.add(v); - skippingIndices.add(v.getIndex()); - } - } - HandleIndex index = new HandleIndex().skipping(skippingIndices); - Map> entries = pidRecord.getEntries(); - - for (Entry> entry : entries.entrySet()) { - for (PIDRecordEntry val : entry.getValue()) { - String key = val.getKey(); - HandleValue hv = new HandleValue(); - int i = index.nextIndex(); - hv.setIndex(i); - hv.setType(key.getBytes(StandardCharsets.UTF_8)); - hv.setData(val.getValue().getBytes(StandardCharsets.UTF_8)); - result.add(hv); - LOG.debug("Entry: ({}) {} <-> {}", i, key, val); - } - } - assert result.size() >= pidRecord.getEntries().keySet().size(); - return result; - } - - protected static class HandleIndex { - // handle record indices start at 1 - private int index = 1; - private List skipping = new ArrayList<>(); - - public final int nextIndex() { - int result = index; - index += 1; - if (index == this.getHsAdminIndex() || skipping.contains(index)) { - index += 1; - } - return result; - } - - public HandleIndex skipping(List skipThose) { - this.skipping = skipThose; - return this; - } - - public final int getHsAdminIndex() { - return 100; - } - } - /** * Returns true if the PID is valid according to the following criteria: * - PID is valid according to isIdentifierRegistered @@ -493,7 +337,7 @@ protected boolean isValidPID(final String pid) { return false; } try { - if (!this.isIdentifierRegistered(pid)) { + if (!this.isPidRegistered(pid)) { return false; } } catch (ExternalServiceException e) { @@ -501,96 +345,4 @@ protected boolean isValidPID(final String pid) { } return true; } - - /** - * Checks if a given value is considered an "internal" or "handle-native" value. - *

- * This may be used to filter out administrative information from a PID record. - * - * @param v the value to check. - * @return true, if the value is conidered "handle-native". - */ - protected boolean isHandleInternalValue(HandleValue v) { - boolean isInternalValue = false; - for (byte[][] typeList : BLACKLIST_NONTYPE_LISTS) { - for (byte[] typeCode : typeList) { - isInternalValue = isInternalValue || Arrays.equals(v.getType(), typeCode); - } - } - return isInternalValue; - } - - /** - * Given two Value Maps, it splits the values in those which have been added, - * updated or removed. - * Using this lists, an update can be applied to the old record, to bring it to - * the state of the new record. - */ - protected static class HandleDiff { - private final Collection toAdd = new ArrayList<>(); - private final Collection toUpdate = new ArrayList<>(); - private final Collection toRemove = new ArrayList<>(); - - HandleDiff( - final Map recordOld, - final Map recordNew - ) throws PidUpdateException { - for (Entry old : recordOld.entrySet()) { - boolean wasRemoved = !recordNew.containsKey(old.getKey()); - if (wasRemoved) { - // if a row in the record is not available anymore, we need to delete it - toRemove.add(old.getValue()); - } else { - // otherwise, we should go and update it. - // we could also check for equality, but this is the safe and easy way. - // (the handlevalue classes can be complicated and we'd have to check their - // equality implementation) - toUpdate.add(recordNew.get(old.getKey())); - } - } - for (Entry e : recordNew.entrySet()) { - boolean isNew = !recordOld.containsKey(e.getKey()); - if (isNew) { - // if there is a record which is not in the oldRecord, we need to add it. - toAdd.add(e.getValue()); - } - } - - // runtime testing to avoid messing up record states. - String exceptionMsg = "DIFF NOT VALID. Type: %s. Value: %s"; - for (HandleValue v : toRemove) { - boolean valid = recordOld.containsValue(v) && !recordNew.containsKey(v.getIndex()); - if (!valid) { - String message = String.format(exceptionMsg, "Remove", v.toString()); - throw new PidUpdateException(message); - } - } - for (HandleValue v : toAdd) { - boolean valid = !recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v); - if (!valid) { - String message = String.format(exceptionMsg, "Add", v); - throw new PidUpdateException(message); - } - } - for (HandleValue v : toUpdate) { - boolean valid = recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v); - if (!valid) { - String message = String.format(exceptionMsg, "Update", v); - throw new PidUpdateException(message); - } - } - } - - public HandleValue[] added() { - return this.toAdd.toArray(new HandleValue[] {}); - } - - public HandleValue[] updated() { - return this.toUpdate.toArray(new HandleValue[] {}); - } - - public HandleValue[] removed() { - return this.toRemove.toArray(new HandleValue[] {}); - } - } } diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystem.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystem.java index bb388ef3..8190eb13 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystem.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystem.java @@ -11,7 +11,6 @@ import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; import org.slf4j.Logger; @@ -80,24 +79,14 @@ public Optional getPrefix() { } @Override - public boolean isIdentifierRegistered(String pid) throws ExternalServiceException { + public boolean isPidRegistered(String pid) throws ExternalServiceException { return this.db.existsById(pid); } @Override - public PIDRecord queryAllProperties(String pid) throws PidNotFoundException, ExternalServiceException { + public PIDRecord queryPid(String pid) throws PidNotFoundException, ExternalServiceException { Optional dbo = this.db.findByPid(pid); - if (dbo.isEmpty()) { return null; } - return new PIDRecord(dbo.get()); - } - - @Override - public String queryProperty(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - Optional dbo = this.db.findByPid(pid); - if (dbo.isEmpty()) { throw new PidNotFoundException(pid); } - PIDRecord rec = new PIDRecord(dbo.get()); - if (!rec.hasProperty(typeDefinition.getIdentifier())) { return null; } - return rec.getPropertyValue(typeDefinition.getIdentifier()); + return new PIDRecord(dbo.orElseThrow(() -> new PidNotFoundException(pid))); } @Override @@ -111,7 +100,7 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE } @Override - public boolean updatePID(PIDRecord rec) throws PidNotFoundException, ExternalServiceException, RecordValidationException { + public boolean updatePid(PIDRecord rec) throws PidNotFoundException, ExternalServiceException, RecordValidationException { if (this.db.existsById(rec.getPid())) { this.db.save(new PidDatabaseObject(rec)); return true; @@ -120,25 +109,7 @@ public boolean updatePID(PIDRecord rec) throws PidNotFoundException, ExternalSer } @Override - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - PIDRecord allProps = this.queryAllProperties(pid); - if (allProps == null) {return null;} - // only return properties listed in the type def - Set typeProps = typeDefinition.getAllProperties(); - PIDRecord result = new PIDRecord(); - for (String propID : allProps.getPropertyIdentifiers()) { - if (typeProps.contains(propID)) { - String[] values = allProps.getPropertyValues(propID); - for (String value : values) { - result.addEntry(propID, "", value); - } - } - } - return result; - } - - @Override - public boolean deletePID(String pid) { + public boolean deletePid(String pid) { throw new UnsupportedOperationException("Deleting PIDs is against the P in PID."); } diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java b/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java index 86fc1923..ef5e44ff 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java @@ -4,8 +4,6 @@ import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.domain.Operations; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import java.io.IOException; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; @@ -21,53 +19,9 @@ public interface ITypingService extends IIdentifierSystem { public void validate(PIDRecord pidRecord) throws RecordValidationException, ExternalServiceException; - /** - * Retrieves a type definition - * - * @param typeIdentifier - * @return null if there is no type with given identifier, the definition - * record otherwise. - * @throws IOException - */ - public TypeDefinition describeType(String typeIdentifier) throws IOException; - - /** - * Queries a single property from the PID. - * - * @param pid - * @param propertyIdentifier must be registered in the type registry - * @return a PIDRecord object containing the single property name and - * value or null if the property is undefined. - * @throws IOException - * @throws IllegalArgumentException if the property is defined but ambiguous - * (type registry query returned multiple results). - */ - public PIDRecord queryProperty(String pid, String propertyIdentifier) throws IOException; - - public PIDRecord queryAllProperties(String pid, boolean includePropertyNames) throws IOException; - - /** - * Queries all properties of a type available from the given PID. If - * optional properties are present, they will be returned as well. If there - * are mandatory properties missing (i.e. the record of the given PID does - * not fully conform to the type), the method will NOT fail but simply - * return only those properties that are present. - * - * @param pid - * @param typeIdentifier a type identifier, not a name - * @param includePropertyNames if true, the method will also return property - * names at additional call costs. - * @return a PID information record with property identifiers mapping to - * values. Contains all property values present in the record of the given - * PID that are also specified by the type (mandatory or optional). If the - * pid is not registered, the method returns null. - * @throws IOException - */ - public PIDRecord queryByType(String pid, String typeIdentifier, boolean includePropertyNames) throws IOException; - /** * Returns an operations instance, configured with this typingService. - * + *

* Convenience method for `new Operations(typingService)`. * * @return an operation instance. diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index 43141fe1..19145874 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -2,23 +2,24 @@ import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.RecordValidationException; +import edu.kit.datamanager.pit.common.TypeNotFoundException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pitservice.IValidationStrategy; -import edu.kit.datamanager.pit.util.TypeValidationUtils; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; -import java.util.concurrent.ExecutionException; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; - -import com.google.common.cache.LoadingCache; /** * Validates a PID record using embedded profile(s). - * + *

* - checks if all mandatory attributes are present * - validates all available attributes * - fails if an attribute is not defined within the profile @@ -27,118 +28,97 @@ public class EmbeddedStrictValidatorStrategy implements IValidationStrategy { private static final Logger LOG = LoggerFactory.getLogger(EmbeddedStrictValidatorStrategy.class); - @Autowired - public LoadingCache typeLoader; - - @Autowired - ApplicationProperties applicationProps; + protected final ITypeRegistry typeRegistry; + protected final boolean alwaysAcceptAdditionalAttributes; + protected final Set profileKeys; + + public EmbeddedStrictValidatorStrategy( + ITypeRegistry typeRegistry, + ApplicationProperties config + ) { + this.typeRegistry = typeRegistry; + this.profileKeys = config.getProfileKeys(); + this.alwaysAcceptAdditionalAttributes = config.isValidationAlwaysAllowAdditionalAttributes(); + } @Override - public void validate(PIDRecord pidRecord) throws RecordValidationException, ExternalServiceException { - String profileKey = applicationProps.getProfileKey(); - if (!pidRecord.hasProperty(profileKey)) { - throw new RecordValidationException( - pidRecord, - "Profile attribute not found. Expected key: " + profileKey); + public void validate(PIDRecord pidRecord) + throws RecordValidationException, ExternalServiceException + { + if (pidRecord.getPropertyIdentifiers().isEmpty()) { + throw new RecordValidationException(pidRecord, "Record is empty!"); } - String[] profilePIDs = pidRecord.getPropertyValues(profileKey); - boolean hasProfile = profilePIDs.length > 0; - if (!hasProfile) { + // For each attribute in record, resolve schema and check the value + List> attributeInfoFutures = pidRecord.getPropertyIdentifiers().stream() + // resolve attribute info (type and schema) + .map(this.typeRegistry::queryAttributeInfo) + // validate values using schema + .map(attributeInfoFuture -> attributeInfoFuture.thenApply(attributeInfo -> { + for (String value : pidRecord.getPropertyValues(attributeInfo.pid())) { + boolean isValid = attributeInfo.validate(value); + if (!isValid) { + throw new RecordValidationException( + pidRecord, + "Attribute %s has a non-complying value %s" + .formatted(attributeInfo.pid(), value)); + } + } + return attributeInfo; + })) + // resolve profiles and apply their validation + .map(attributeInfoFuture -> attributeInfoFuture.thenCompose(attributeInfo -> { + boolean indicatesProfileValue = this.profileKeys.contains(attributeInfo.pid()); + if (!indicatesProfileValue) { + return CompletableFuture.completedFuture(attributeInfo); + } + CompletableFuture[] profileFutures = Arrays.stream(pidRecord.getPropertyValues(attributeInfo.pid())) + .map(this.typeRegistry::queryAsProfile) + .map(registeredProfileFuture -> registeredProfileFuture.thenAccept( + registeredProfile -> { + registeredProfile.validateAttributes(pidRecord, this.alwaysAcceptAdditionalAttributes); + })) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(profileFutures).thenApply(v -> attributeInfo); + })) + .toList(); + + + try { + LOG.trace("Processing all attributes in the record {}.", pidRecord.getPid()); + CompletableFuture.allOf(attributeInfoFutures.toArray(new CompletableFuture[0])).join(); + LOG.trace("Finished processing all attributes in the record {}.", pidRecord.getPid()); + } catch (CompletionException e) { + LOG.trace("Exception occurred during validation of record {}. Unpack Exception, if required.", pidRecord.getPid(), e); + unpackAsyncExceptions(pidRecord, e); + LOG.trace("Exception was not unpacked. Rethrowing.", e); + throw new ExternalServiceException(this.typeRegistry.getRegistryIdentifier()); + } catch (CancellationException e) { + unpackAsyncExceptions(pidRecord, e); throw new RecordValidationException( pidRecord, - "Profile attribute " + profileKey + " has no values."); - } - - for (String profilePID : profilePIDs) { - TypeDefinition profileDefinition; - try { - profileDefinition = this.typeLoader.get(profilePID); - } catch (ExecutionException e) { - LOG.error("Could not resolve identifier {}.", profilePID); - throw new ExternalServiceException( - applicationProps.getTypeRegistryUri().toString()); - } - if (profileDefinition == null) { - LOG.error("No type definition found for identifier {}.", profilePID); - throw new RecordValidationException( - pidRecord, - String.format("No type found for identifier %s.", profilePID)); - } - - LOG.debug("validating profile {}", profilePID); - this.strictProfileValidation(pidRecord, profileDefinition); - LOG.debug("successfully validated {}", profilePID); + String.format("Validation task was cancelled for %s. Please report.", pidRecord.getPid())); } } /** - * Exceptions indicate failure. No Exceptions mean success. - * - * @param pidRecord the PID record to validate. - * @param profile the profile to validate against. - * @throws RecordValidationException with error message on validation errors. + * Checks Exceptions' causes for a RecordValidationExceptions, and throws them, if present. + *

+ * Usually used to avoid exposing exceptions related to futures. + * @param e the exception to unwrap. */ - private void strictProfileValidation(PIDRecord pidRecord, TypeDefinition profile) throws RecordValidationException { - // if (profile.hasSchema()) { - // TODO issue https://github.com/kit-data-manager/pit-service/issues/104 - // validate using schema and you are done (strict validation) - // String jsonRecord = ""; // TODO format depends on schema source - // return profile.validate(jsonRecord); - // } - - LOG.trace("Validating PID record against type definition."); - - TypeValidationUtils.checkMandatoryAttributes(pidRecord, profile); - - for (String attributeKey : pidRecord.getPropertyIdentifiers()) { - LOG.trace("Checking PID record key {}.", attributeKey); - - TypeDefinition type = profile.getSubTypes().get(attributeKey); - if (type == null) { - LOG.error("No sub-type found for key {}.", attributeKey); - // TODO try to resolve it (for later when we support "allow additional - // attributes") - // if profile.allowsAdditionalAttributes() {...} else - throw new RecordValidationException( - pidRecord, - String.format("Attribute %s is not allowed in profile %s", - attributeKey, - profile.getIdentifier())); - } - - validateValuesForKey(pidRecord, attributeKey, type); - } - } - - /** - * Validates all values of an attribute against a given type definition. - * - * @param pidRecord the record containing the attribute and value. - * @param attributeKey the attribute to check the values for. - * @param type the type definition to check against. - * @throws RecordValidationException on error. - */ - private void validateValuesForKey(PIDRecord pidRecord, String attributeKey, TypeDefinition type) - throws RecordValidationException { - String[] values = pidRecord.getPropertyValues(attributeKey); - for (String value : values) { - if (value == null) { - LOG.error("'null' record value found for key {}.", attributeKey); - throw new RecordValidationException( - pidRecord, - String.format("Validation of value %s against type %s failed.", - value, - type.getIdentifier())); - } - - if (!type.validate(value)) { - LOG.error("Validation of value {} against type {} failed.", value, type.getIdentifier()); + private static void unpackAsyncExceptions(PIDRecord pidRecord, Throwable e) { + final int MAX_LEVEL = 10; + Throwable cause = e; + + for (int level = 0; level <= MAX_LEVEL && cause != null; level++) { + cause = cause.getCause(); + if (cause instanceof RecordValidationException rve) { + throw rve; + } else if (cause instanceof TypeNotFoundException tnf) { throw new RecordValidationException( pidRecord, - String.format("Validation of value %s against type %s failed.", - value, - type.getIdentifier())); + "Type not found: %s".formatted(tnf.getMessage())); } } } diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java index 0e556933..d9a2fc99 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java @@ -1,27 +1,24 @@ package edu.kit.datamanager.pit.pitservice.impl; -import com.google.common.cache.LoadingCache; import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.common.PidAlreadyExistsException; import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.common.RecordValidationException; -import edu.kit.datamanager.pit.common.TypeNotFoundException; -import java.io.IOException; import java.util.Collection; -import java.util.HashSet; -import java.util.List; import java.util.Optional; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import edu.kit.datamanager.pit.pitservice.ITypingService; import edu.kit.datamanager.pit.pitservice.IValidationStrategy; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.domain.Operations; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import java.util.concurrent.ExecutionException; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,8 +35,6 @@ public class TypingService implements ITypingService { private static final String LOG_MSG_TYPING_SERVICE_MISCONFIGURED = "Typing service misconfigured."; private static final String LOG_MSG_QUERY_TYPE = "Querying for type with identifier {}."; - - protected final LoadingCache typeCache; protected final IIdentifierSystem identifierSystem; protected final ITypeRegistry typeRegistry; @@ -53,12 +48,10 @@ public class TypingService implements ITypingService { @Autowired protected IValidationStrategy defaultStrategy = null; - public TypingService(IIdentifierSystem identifierSystem, ITypeRegistry typeRegistry, - LoadingCache typeCache) { + public TypingService(IIdentifierSystem identifierSystem, ITypeRegistry typeRegistry) { super(); this.identifierSystem = identifierSystem; this.typeRegistry = typeRegistry; - this.typeCache = typeCache; } @Override @@ -78,15 +71,9 @@ public void validate(PIDRecord pidRecord) } @Override - public boolean isIdentifierRegistered(String pid) throws ExternalServiceException { + public boolean isPidRegistered(String pid) throws ExternalServiceException { LOG.trace("Performing isIdentifierRegistered({}).", pid); - return identifierSystem.isIdentifierRegistered(pid); - } - - @Override - public String queryProperty(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - LOG.trace("Performing queryProperty({}, TypeDefinition#{}).", pid, typeDefinition.getIdentifier()); - return identifierSystem.queryProperty(pid, typeDefinition); + return identifierSystem.isPidRegistered(pid); } @Override @@ -96,47 +83,20 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE } @Override - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - LOG.trace("Performing queryByType({}, TypeDefinition#{}).", pid, typeDefinition.getIdentifier()); - return identifierSystem.queryByType(pid, typeDefinition); - } - - @Override - public boolean deletePID(String pid) throws ExternalServiceException { + public boolean deletePid(String pid) throws ExternalServiceException { LOG.trace("Performing deletePID({}).", pid); - return identifierSystem.deletePID(pid); + return identifierSystem.deletePid(pid); } @Override - public TypeDefinition describeType(String typeIdentifier) throws IOException { - LOG.trace("Performing describeType({}).", typeIdentifier); - try { - LOG.trace(LOG_MSG_QUERY_TYPE, typeIdentifier); - return typeCache.get(typeIdentifier); - } catch (ExecutionException ex) { - LOG.error("Failed to query for type with identifier " + typeIdentifier + ".", ex); - throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); - } - } - - @Override - public PIDRecord queryAllProperties(String pid) throws PidNotFoundException, ExternalServiceException { - LOG.trace("Performing queryAllProperties({}).", pid); - PIDRecord pidRecord = identifierSystem.queryAllProperties(pid); - if (pidRecord == null) { - throw new PidNotFoundException(pid); - } - // ensure the PID is always contained - pidRecord.setPid(pid); - return pidRecord; + public PIDRecord queryPid(String pid) throws PidNotFoundException, ExternalServiceException { + return queryPid(pid, false); } - @Override - public PIDRecord queryAllProperties(String pid, boolean includePropertyNames) - throws IOException { + public PIDRecord queryPid(String pid, boolean includePropertyNames) + throws PidNotFoundException, ExternalServiceException { LOG.trace("Performing queryAllProperties({}, {}).", pid, includePropertyNames); - PIDRecord pidInfo = identifierSystem.queryAllProperties(pid); - LOG.trace("PID record found. {}", (includePropertyNames) ? "Adding property names." : "Returning result."); + PIDRecord pidInfo = identifierSystem.queryPid(pid); if (includePropertyNames) { enrichPIDInformationRecord(pidInfo); @@ -144,41 +104,20 @@ public PIDRecord queryAllProperties(String pid, boolean includePropertyNames) return pidInfo; } - @Override - public PIDRecord queryProperty(String pid, String propertyIdentifier) throws IOException { - LOG.trace("Performing queryProperty({}, {}).", pid, propertyIdentifier); - PIDRecord pidInfo = new PIDRecord(); - // query type registry - TypeDefinition typeDef; - try { - LOG.trace(LOG_MSG_QUERY_TYPE, propertyIdentifier); - typeDef = typeCache.get(propertyIdentifier); - } catch (ExecutionException ex) { - LOG.error(LOG_MSG_QUERY_TYPE, propertyIdentifier); - - throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); - } - - if (typeDef != null) { - pidInfo.addEntry(propertyIdentifier, typeDef.getName(), identifierSystem.queryProperty(pid, typeDef)); - return pidInfo; - } - return null; - } - private void enrichPIDInformationRecord(PIDRecord pidInfo) { // enrich record by querying type registry for all property definitions // to get the property names for (String typeIdentifier : pidInfo.getPropertyIdentifiers()) { - TypeDefinition typeDef; + AttributeInfo attributeInfo; try { - typeDef = typeCache.get(typeIdentifier); - } catch (ExecutionException ex) { + attributeInfo = this.typeRegistry.queryAttributeInfo(typeIdentifier).join(); + } catch (CompletionException | CancellationException ex) { + // TODO convert exceptions like in validation service. throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); } - if (typeDef != null) { - pidInfo.setPropertyName(typeIdentifier, typeDef.getName()); + if (attributeInfo != null) { + pidInfo.setPropertyName(typeIdentifier, attributeInfo.name()); } else { pidInfo.setPropertyName(typeIdentifier, typeIdentifier); } @@ -186,37 +125,8 @@ private void enrichPIDInformationRecord(PIDRecord pidInfo) { } @Override - public PIDRecord queryByType(String pid, String typeIdentifier, boolean includePropertyNames) - throws IOException { - TypeDefinition typeDef; - try { - typeDef = typeCache.get(typeIdentifier); - } catch (ExecutionException ex) { - throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); - } - - if (typeDef == null) { - return null; - } - // now query PID record and fill in information based on property keys in type definition - PIDRecord result = identifierSystem.queryByType(pid, typeDef); - if (includePropertyNames) { - enrichPIDInformationRecord(result); - } - return result; - } - - public ITypeRegistry getTypeRegistry() { - return typeRegistry; - } - - public IIdentifierSystem getIdentifierSystem() { - return identifierSystem; - } - - @Override - public boolean updatePID(PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException { - return this.identifierSystem.updatePID(pidRecord); + public boolean updatePid(PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException { + return this.identifierSystem.updatePid(pidRecord); } @Override @@ -225,7 +135,7 @@ public Collection resolveAllPidsOfPrefix() throws ExternalServiceExcepti } public Operations getOperations() { - return new Operations(this); + return new Operations(this.typeRegistry, this.identifierSystem); } } diff --git a/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java b/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java new file mode 100644 index 00000000..66ac9044 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java @@ -0,0 +1,89 @@ +package edu.kit.datamanager.pit.resolver; + +import edu.kit.datamanager.pit.common.ExternalServiceException; +import edu.kit.datamanager.pit.common.PidNotFoundException; +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleBehavior; +import edu.kit.datamanager.pit.pitservice.ITypingService; +import net.handle.api.HSAdapter; +import net.handle.api.HSAdapterFactory; +import net.handle.hdllib.HandleException; +import net.handle.hdllib.HandleValue; + +import java.util.Arrays; +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Universal resolver. Will not only resolve PIDs from the configured PID system, + * but also from other external systems, to which it has read-only access to. + *

+ * Currently implemented read-only systems: + *

+ * - Handle System + */ +public class Resolver { + /** + * The configured system to which we usually have write access. + * Only used if the prefix of the PID matches the prefix of this system. + */ + private final ITypingService identifierSystem; + + /** + * The client to the Handle System, used in read-only mode. + * Used as a fallback, if the prefix is not the one of the configured system. + */ + private final HSAdapter client = HSAdapterFactory.newInstance(); + private static final String SERVICE_NAME_HANDLE = "Handle System (read-only access)"; + + public Resolver(ITypingService identifierSystem) { + this.identifierSystem = identifierSystem; + } + + /** + * Resolves a PID to a PIDRecord. + *

+ * Takes advantage of administrative access to the configured PID system, if possible. + * Otherwise, falls back to read-only access to external systems. + * + * @param pid the PID to resolve. + * @return the PIDRecord associated with the PID. + * @throws PidNotFoundException if the PID could not be found in any system. + * @throws ExternalServiceException if there was an error with the communication to an external system. + */ + public PIDRecord resolve(String pid) throws PidNotFoundException, ExternalServiceException { + String prefix = Arrays.stream( + pid.split("/", 2) + ) + .findFirst() + .orElseThrow(() -> new PidNotFoundException(pid, "Could not find prefix in PID.")) + + "/"; // needed because the prefix is always followed by a slash + boolean isInConfiguredIdentifierSystem = this.identifierSystem != null && this.identifierSystem.getPrefix() + .map(prefix::equals) + .orElse(false); + if (isInConfiguredIdentifierSystem) { + return this.identifierSystem.queryPid(pid); + } else { + try { + Collection recordProperties = Arrays.stream(this.client.resolveHandle(pid, null, null)) + .filter(value -> !HandleBehavior.isHandleInternalValue(value)) + .collect(Collectors.toList()); + return HandleBehavior.recordFrom(recordProperties).withPID(pid); + } catch (HandleException e) { + int code = e.getCode(); + boolean isExistingPid = code == HandleException.HANDLE_DOES_NOT_EXIST; + boolean missingPrefixHost = false; + if (e.getCause() instanceof HandleException inner) { + int innerCode = inner.getCode(); + missingPrefixHost = innerCode == HandleException.SERVICE_NOT_FOUND + || innerCode == HandleException.HANDLE_DOES_NOT_EXIST; + } + if (isExistingPid || missingPrefixHost) { + throw new PidNotFoundException(pid, e); + } else { + throw new ExternalServiceException(SERVICE_NAME_HANDLE, e); + } + } + } + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java new file mode 100644 index 00000000..2bfe77c1 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -0,0 +1,77 @@ +package edu.kit.datamanager.pit.typeregistry; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.ValidationMessage; +import edu.kit.datamanager.pit.Application; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.Objects; +import java.util.Set; + +/** + * @param pid the pid of this attribute + * @param name a human-readable name, defined in the DTR for this type. + * Note this is usually different from the name in a specific profile! + * @param typeName name of the schema type of this attribute in the DTR, + * e.g. "Profile", "InfoType", "Special-Info-Type", ... + * @param jsonSchema the json schema to validate a value of this attribute + */ +public record AttributeInfo( + String pid, + String name, + String typeName, + Collection jsonSchema +) { + private static final Logger log = LoggerFactory.getLogger(AttributeInfo.class); + + public boolean validate(String value) { + return this.jsonSchema().stream() + .filter(schemaInfo -> schemaInfo.error() == null) + .filter(schemaInfo -> schemaInfo.schema() != null) + .peek(schemaInfo -> log.warn("Found valid schema from {} to validate {} / {}.", schemaInfo.origin(), pid, value)) + .anyMatch(schemaInfo -> this.validate(schemaInfo.schema(), value)); + } + + private boolean validate(JsonSchema schema, String value) { + try { + JsonNode toValidate = valueToJsonNode(value); + Set errors = schema.validate(toValidate, executionContext -> { + // By default, since Draft 2019-09, the format keyword only generates annotations and not assertions + executionContext.getExecutionConfig().setFormatAssertionsEnabled(true); + }); + if (!errors.isEmpty()) { + log.warn("Validation errors for value '{}': {}", value, errors); + } + return errors.isEmpty(); + } catch (Exception e) { + log.error("Exception during validation for value '{}': {}", value, e.getMessage(), e); + return false; + } + } + + /** + * Converts the given value to a JsonNode. + * + * @param value the value to convert + * @return a JsonNode representation of the value + */ + public static JsonNode valueToJsonNode(String value) { + JsonNode toValidate; + if (value.isBlank()) { + return new TextNode(value); + } + try { + toValidate = Application.jsonObjectMapper().readTree(value); + } catch (JsonProcessingException e) { + log.warn("Failed to parse value '{}' as JSON, treating it as a plain text node.", value); + toValidate = new TextNode(value); + } + return toValidate; + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/ITypeRegistry.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/ITypeRegistry.java index d5932e83..79d9eeef 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/ITypeRegistry.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/ITypeRegistry.java @@ -1,10 +1,6 @@ package edu.kit.datamanager.pit.typeregistry; -import java.io.IOException; - -import edu.kit.datamanager.pit.domain.TypeDefinition; - -import java.net.URISyntaxException; +import java.util.concurrent.CompletableFuture; /** * Main abstraction interface towards the type registry. Contains all methods @@ -12,13 +8,13 @@ * */ public interface ITypeRegistry { + CompletableFuture queryAttributeInfo(String attributePid); + CompletableFuture queryAsProfile(String profilePid); /** - * Queries a type definition record from the type registry. + * An identifier for exceptions and debugging purposes. * - * @param typeIdentifier - * @return a type definition record or null if the type is not registered. - * @throws IOException on communication errors with a remote registry + * @return a name ur url string that identifies the implementation or configuration well in case of errors. */ - public TypeDefinition queryTypeDefinition(String typeIdentifier) throws IOException, URISyntaxException; + String getRegistryIdentifier(); } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java new file mode 100644 index 00000000..661e1907 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java @@ -0,0 +1,57 @@ +package edu.kit.datamanager.pit.typeregistry; + +import edu.kit.datamanager.pit.common.RecordValidationException; +import edu.kit.datamanager.pit.domain.ImmutableList; +import edu.kit.datamanager.pit.domain.PIDRecord; + +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +public record RegisteredProfile( + String pid, + boolean allowAdditionalAttributes, + ImmutableList attributes +) { + public void validateAttributes( + PIDRecord pidRecord, + boolean alwaysAllowAdditionalAttributes + ) throws RecordValidationException + { + Set attributesNotDefinedInProfile = pidRecord.getPropertyIdentifiers().stream() + .filter(recordKey -> attributes.items().stream().noneMatch( + profileAttribute -> Objects.equals(profileAttribute.pid(), recordKey))) + .collect(Collectors.toSet()); + + + boolean additionalAttributesForbidden = !this.allowAdditionalAttributes && !alwaysAllowAdditionalAttributes; + boolean violatesAdditionalAttributes = additionalAttributesForbidden && !attributesNotDefinedInProfile.isEmpty(); + if (violatesAdditionalAttributes) { + throw new RecordValidationException( + pidRecord, + String.format("Attributes %s are not allowed in profile %s", + String.join(", ", attributesNotDefinedInProfile), + this.pid) + ); + } + + for (RegisteredProfileAttribute profileAttribute : this.attributes.items()) { + if (profileAttribute.violatesMandatoryProperty(pidRecord)) { + throw new RecordValidationException( + pidRecord, + String.format("Attribute %s missing, but is mandatory in profile %s", + profileAttribute.pid(), + this.pid) + ); + } + if (profileAttribute.violatesRepeatableProperty(pidRecord)) { + throw new RecordValidationException( + pidRecord, + String.format("Attribute %s is not repeatable in profile %s, but has multiple values", + profileAttribute.pid(), + this.pid) + ); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java new file mode 100644 index 00000000..0469d296 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java @@ -0,0 +1,20 @@ +package edu.kit.datamanager.pit.typeregistry; + +import edu.kit.datamanager.pit.domain.PIDRecord; + +public record RegisteredProfileAttribute( + String pid, + boolean mandatory, + boolean repeatable +) { + public boolean violatesMandatoryProperty(PIDRecord pidRecord) { + boolean contains = pidRecord.getPropertyIdentifiers().contains(this.pid) + && pidRecord.getPropertyValues(this.pid).length > 0; + return this.mandatory && !contains; + } + + public boolean violatesRepeatableProperty(PIDRecord pidRecord) { + boolean repeats = pidRecord.getPropertyValues(this.pid).length > 1; + return !this.repeatable && repeats; + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java new file mode 100644 index 00000000..26a6c2b9 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -0,0 +1,214 @@ +package edu.kit.datamanager.pit.typeregistry.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import edu.kit.datamanager.pit.Application; +import edu.kit.datamanager.pit.common.ExternalServiceException; +import edu.kit.datamanager.pit.common.InvalidConfigException; +import edu.kit.datamanager.pit.common.TypeNotFoundException; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.domain.ImmutableList; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; +import edu.kit.datamanager.pit.typeregistry.RegisteredProfile; +import edu.kit.datamanager.pit.typeregistry.RegisteredProfileAttribute; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.StreamSupport; + +public class TypeApi implements ITypeRegistry { + + private static final Logger LOG = LoggerFactory.getLogger(TypeApi.class); + + protected final URL baseUrl; + protected final RestClient http; + protected final AsyncLoadingCache profileCache; + protected final AsyncLoadingCache attributeCache; + + protected final SchemaSetGenerator schemaSetGenerator; + + public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGenerator) { + this.schemaSetGenerator = schemaSetGenerator; + this.baseUrl = properties.getTypeRegistryUri(); + String baseUri; + try { + baseUri = baseUrl.toURI().resolve("v1/types/").toString(); + } catch (URISyntaxException e) { + throw new InvalidConfigException("Type-Api base url not valid: " + baseUrl); + } + this.http = RestClient.builder() + .baseUrl(baseUri) + .requestInterceptor((request, body, execution) -> { + long start = System.currentTimeMillis(); + ClientHttpResponse response = execution.execute(request, body); + long timeSpan = System.currentTimeMillis() - start; + boolean isLongRequest = timeSpan > Application.LONG_HTTP_REQUEST_THRESHOLD; + if (isLongRequest) { + LOG.warn("Long http request to {} ({}ms)", request.getURI(), timeSpan); + } + return response; + }) + .build(); + + int maximumSize = properties.getCacheMaxEntries(); + long expireAfterWrite = properties.getCacheExpireAfterWriteLifetime(); + + this.profileCache = Caffeine.newBuilder() + .maximumSize(maximumSize) + .executor(Application.newExecutor()) + .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) + .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) + .removalListener((key, value, cause) -> + LOG.trace("Removing profile {} from profile cache. Cause: {}", key, cause) + ) + .buildAsync(maybeProfilePid -> { + LOG.trace("Loading profile {} to cache.", maybeProfilePid); + return this.queryProfile(maybeProfilePid); + }); + + this.attributeCache = Caffeine.newBuilder() + .maximumSize(maximumSize) + .executor(Application.newExecutor()) + .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) + .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) + .removalListener((key, value, cause) -> + LOG.trace("Removing profile {} from profile cache. Cause: {}", key, cause) + ) + .buildAsync(attributePid -> { + LOG.trace("Loading attribute {} to cache.", attributePid); + return this.queryAttribute(attributePid); + }); + } + + protected AttributeInfo queryAttribute(String attributePid) { + return http.get() + .uri(uriBuilder -> uriBuilder + .path(attributePid) + .build()) + .exchange((clientRequest, clientResponse) -> { + if (clientResponse.getStatusCode().is2xxSuccessful()) { + try (InputStream inputStream = clientResponse.getBody()) { + String body = new String(inputStream.readAllBytes()); + return extractAttributeInformation(attributePid, Application.jsonObjectMapper().readTree(body)); + } catch (IOException e) { + throw new TypeNotFoundException(attributePid); + } + } else { + throw new TypeNotFoundException(attributePid); + } + }); + } + + protected AttributeInfo extractAttributeInformation(String attributePid, JsonNode jsonNode) { + String typeName = jsonNode.path("type").asText(); + String name = jsonNode.path("name").asText(); + Set schemas = this.querySchemas(attributePid); + return new AttributeInfo(attributePid, name, typeName, schemas); + } + + protected Set querySchemas(String maybeSchemaPid) throws TypeNotFoundException, ExternalServiceException { + return schemaSetGenerator.generateFor(maybeSchemaPid).join(); + } + + protected RegisteredProfile queryProfile(String maybeProfilePid) throws TypeNotFoundException, ExternalServiceException { + return http.get() + .uri(uriBuilder -> uriBuilder + .path(maybeProfilePid) + .build()) + .exchange((clientRequest, clientResponse) -> { + if (clientResponse.getStatusCode().is2xxSuccessful()) { + InputStream inputStream = clientResponse.getBody(); + String body = new String(inputStream.readAllBytes()); + inputStream.close(); + return extractProfileInformation(maybeProfilePid, Application.jsonObjectMapper().readTree(body)); + } else { + throw new TypeNotFoundException(maybeProfilePid); + } + }); + } + + protected RegisteredProfile extractProfileInformation(String profilePid, JsonNode typeApiResponse) + throws TypeNotFoundException, ExternalServiceException { + + List attributes = new ArrayList<>(); + typeApiResponse.path("content").path("properties").forEach(item -> { + + String attributePid = Optional.ofNullable(item.path("pid").asText(null)) + .or(() -> Optional.ofNullable(item.path("identifier").asText(null))) + .or(() -> Optional.ofNullable(item.path("id").asText())) + .orElse(""); + + JsonNode representations = item.path("representationsAndSemantics").path(0); + + JsonNode obligationNode = representations.path("obligation"); + boolean attributeMandatory = obligationNode.isBoolean() ? obligationNode.asBoolean() + : List.of("mandatory", "yes", "true").contains(obligationNode.asText().trim().toLowerCase()); + + JsonNode repeatableNode = representations.path("repeatable"); + boolean attributeRepeatable = repeatableNode.isBoolean() ? repeatableNode.asBoolean() + : List.of("yes", "true", "repeatable").contains(repeatableNode.asText().trim().toLowerCase()); + + RegisteredProfileAttribute attribute = new RegisteredProfileAttribute( + attributePid, + attributeMandatory, + attributeRepeatable); + + if (obligationNode.isNull() || repeatableNode.isNull() || attributePid.trim().isEmpty()) { + throw new ExternalServiceException(baseUrl.toString(), "Malformed attribute in profile (%s): " + attribute); + } + attributes.add(attribute); + + }); + + boolean additionalAttributesDtrTestStyle = StreamSupport.stream(typeApiResponse + .path("content") + .path("representationsAndSemantics") + .spliterator(), + true) + .filter(JsonNode::isObject) + .filter(node -> node.path("expression").asText("").equals("Format")) + .map(node -> node.path("subSchemaRelation").asText("").equals("allowAdditionalProperties")) + .findFirst() + .orElse(true); + boolean additionalAttributesEoscStyle = typeApiResponse + .path("content") + .path("addProps") + .asBoolean(true); + // As the default is true, we assume that additional attributes are disallowed if one indicator is false: + boolean profileDefinitionAllowsAdditionalAttributes = additionalAttributesDtrTestStyle && additionalAttributesEoscStyle; + + return new RegisteredProfile(profilePid, profileDefinitionAllowsAdditionalAttributes, new ImmutableList<>(attributes)); + } + + @Override + public CompletableFuture queryAttributeInfo(String attributePid) { + return this.attributeCache.get(attributePid); + } + + @Override + public CompletableFuture queryAsProfile(String profilePid) { + return this.profileCache.get(profilePid); + } + + @Override + public String getRegistryIdentifier() { + return baseUrl.toString(); + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java deleted file mode 100644 index 0d016d27..00000000 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java +++ /dev/null @@ -1,214 +0,0 @@ -package edu.kit.datamanager.pit.typeregistry.impl; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.cache.LoadingCache; -import edu.kit.datamanager.pit.configuration.ApplicationProperties; -import edu.kit.datamanager.pit.domain.ProvenanceInformation; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; -import java.net.URISyntaxException; -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.Date; -import java.util.concurrent.ExecutionException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -/** - * Accessor for a specific instance of a TypeRegistry. The TypeRegistry is - * uniquely identified by a baseUrl and an identifierPrefix which all types of - * this particular registry are using. The prefix also allows to determine, - * whether a given PID might be a type or property registered at this - * TypeRegistry. - */ -public class TypeRegistry implements ITypeRegistry { - - private static final Logger LOG = LoggerFactory.getLogger(TypeRegistry.class); - @Autowired - public LoadingCache typeCache; - @Autowired - private ApplicationProperties applicationProperties; - - protected RestTemplate restTemplate = new RestTemplate(); - - @Override - public TypeDefinition queryTypeDefinition(String typeIdentifier) throws IOException, URISyntaxException { - LOG.trace("Performing queryTypeDefinition({}).", typeIdentifier); - String[] segments = typeIdentifier.split("/"); - UriComponentsBuilder uriBuilder = UriComponentsBuilder - .fromUri( - applicationProperties - .getHandleBaseUri() - .toURI()) - .pathSegment(segments); - LOG.trace("Querying for type definition at URI {}.", uriBuilder); - ResponseEntity response = restTemplate.exchange(uriBuilder.build().toUri(), HttpMethod.GET, - HttpEntity.EMPTY, String.class); - ObjectMapper mapper = new ObjectMapper(); - JsonNode rootNode = mapper.readTree(response.getBody()); - LOG.trace("Constructing type definition from response."); - return constructTypeDefinition(rootNode); - } - - /** - * Helper method to construct a type definition from a JSON response - * received from the TypeRegistry. - * - * @param rootNode The type definition. - * - * @return The TypeDefinition as object. - */ - private TypeDefinition constructTypeDefinition(JsonNode rootNode) - throws JsonProcessingException, IOException, URISyntaxException { - // TODO We are doing things too complicated here. Deserialization should be - // easy. - // But before we change the domain model to do so, we need a lot of tests to - // make sure things work as before after the changes. - LOG.trace("Performing constructTypeDefinition()."); - JsonNode entry = rootNode; - Map properties = new HashMap<>(); - LOG.trace("Checking for 'properties' attribute."); - if (entry.has("properties")) { - LOG.trace("'properties' attribute found. Transferring properties to type definition."); - for (JsonNode entryKV : entry.get("properties")) { - LOG.trace("Checking for 'name' property."); - if (!entryKV.has("name")) { - LOG.trace("No 'name' property found. Skipping property {}.", entryKV); - continue; - } - - String key = entryKV.get("name").asText(); - - if (!entryKV.has("identifier")) { - LOG.trace("No 'identifier' property found. Skipping property {}.", entryKV); - continue; - } - - String value = entryKV.get("identifier").asText(); - LOG.trace("Creating type definition instance for identifier {}.", value); - TypeDefinition type_def; - - try { - type_def = typeCache.get(value); - } catch (ExecutionException ex) { - throw new IOException("Failed to obtain type definition via cache.", ex); - } - - LOG.trace("Checking for sub-types in 'representationsAndSemantics' property."); - if (entryKV.has("representationsAndSemantics")) { - LOG.trace( - "'representationsAndSemantics' attribute found. Transferring properties to type definition."); - JsonNode semNode = entryKV.get("representationsAndSemantics"); - semNode = semNode.get(0); - LOG.trace("Checking for 'expression' property."); - if (semNode.has("expression")) { - LOG.trace("Setting 'expression' value {}.", semNode.get("expression").asText()); - type_def.setExpression(semNode.get("expression").asText()); - } - - LOG.trace("Checking for 'value' property."); - if (semNode.has("value")) { - LOG.trace("Setting 'value' value {}.", semNode.get("value").asText()); - type_def.setValue(semNode.get("value").asText()); - } - - LOG.trace("Checking for 'obligation' property."); - if (semNode.has("obligation")) { - LOG.trace("Setting 'obligation' value {}.", semNode.get("obligation").asText()); - String obligation = semNode.get("obligation").asText(); - type_def.setOptional("Optional".equalsIgnoreCase(obligation)); - } - - LOG.trace("Checking for 'repeatable' property."); - if (semNode.has("repeatable")) { - LOG.trace("Setting 'repeatable' value {}.", semNode.get("repeatable").asText()); - String repeatable = semNode.get("repeatable").asText(); - type_def.setRepeatable(!"No".equalsIgnoreCase(repeatable)); - } - } - LOG.trace("Adding new sub-type with key {}.", key); - properties.put(key, type_def); - } - } - String typeUseExpl = null; - if (entry.has("description")) { - typeUseExpl = entry.get("description").asText(); - } - String name = null; - if (entry.has("name")) { - name = entry.get("name").asText(); - } - - if (!entry.has("identifier")) { - LOG.error("No 'identifier' property found in entry: {}", entry); - throw new IOException("No 'identifier' attribute found in type definition."); - } - String identifier = entry.get("identifier").asText(); - - TypeDefinition result = new TypeDefinition(); - result.setName(name); - result.setDescription(typeUseExpl); - result.setIdentifier(identifier); - LOG.trace("Checking for 'validationSchema' property."); - if (entry.has("validationSchema")) { - String validationSchema = entry.get("validationSchema").asText(); - result.setSchema(validationSchema); - } - - LOG.trace("Checking for 'provenance' property."); - if (entry.has("provenance")) { - ProvenanceInformation prov = new ProvenanceInformation(); - JsonNode provNode = entry.get("provenance"); - if (provNode.has("creationDate")) { - String creationDate = provNode.get("creationDate").asText(); - try { - prov.setCreationDate(Date.from(Instant.parse(creationDate))); - } catch (DateTimeParseException ex) { - LOG.error("Failed to parse creationDate from value " + creationDate + ".", ex); - } - } - if (provNode.has("lastModificationDate")) { - String lastModificationDate = provNode.get("lastModificationDate").asText(); - try { - prov.setLastModificationDate(Date.from(Instant.parse(lastModificationDate))); - } catch (DateTimeParseException ex) { - LOG.error("Failed to parse lastModificationDate from value " + lastModificationDate + ".", ex); - } - } - for (JsonNode entryKV : provNode.get("contributors")) { - String identified = null; - String contributorName = null; - String details = null; - - if (entry.has("identifiedBy")) { - identified = entryKV.get("identifiedBy").asText(); - } - if (entry.has("name")) { - contributorName = entryKV.get("name").asText(); - } - if (entry.has("details")) { - details = entryKV.get("details").asText(); - } - prov.addContributor(identified, contributorName, details); - } - result.setProvenance(prov); - } - - LOG.trace("Finalizing and returning type definition."); - properties.keySet().forEach(pd -> result.addSubType(properties.get(pd))); - this.typeCache.put(identifier, result); - return result; - } -} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java new file mode 100644 index 00000000..2167eae7 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java @@ -0,0 +1,103 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import edu.kit.datamanager.pit.Application; +import edu.kit.datamanager.pit.common.ExternalServiceException; +import edu.kit.datamanager.pit.common.InvalidConfigException; +import edu.kit.datamanager.pit.common.TypeNotFoundException; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import jakarta.validation.constraints.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; + +public class DtrTestSchemaGenerator implements SchemaGenerator { + private static final Logger LOG = LoggerFactory.getLogger(DtrTestSchemaGenerator.class); + + protected static final String ORIGIN = "dtr-test"; + protected final URI baseUrl; + protected final RestClient http; + JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4); + + public DtrTestSchemaGenerator(@NotNull ApplicationProperties props) { + try { + this.baseUrl = props.getHandleBaseUri().toURI(); + } catch (URISyntaxException e) { + throw new InvalidConfigException("BaseUrl not configured properly."); + } + HttpClient httpClient = java.net.http.HttpClient.newBuilder() + .followRedirects(java.net.http.HttpClient.Redirect.NORMAL) + .build(); + this.http = RestClient.builder() + .baseUrl(this.baseUrl.toString()) + .requestFactory(new JdkClientHttpRequestFactory(httpClient)) + .requestInterceptor((request, body, execution) -> { + long start = System.currentTimeMillis(); + ClientHttpResponse response = execution.execute(request, body); + long timeSpan = System.currentTimeMillis() - start; + boolean isLongRequest = timeSpan > Application.LONG_HTTP_REQUEST_THRESHOLD; + if (isLongRequest) { + LOG.warn("Long http request to {} ({}ms)", request.getURI(), timeSpan); + } + return response; + }) + .build(); + } + + @Override + public SchemaInfo generateSchema(@NotNull String maybeTypePid) { + return this.http.get().uri(uriBuilder -> uriBuilder.pathSegment(maybeTypePid).build()) + .exchange((request, response) -> { + HttpStatusCode status = response.getStatusCode(); + if (status.is2xxSuccessful()) { + JsonSchema schema = null; + try (InputStream inputStream = response.getBody()) { + JsonNode schemaNode = Application.jsonObjectMapper().readTree( + Application.jsonObjectMapper() + .readTree(inputStream) + .path("validationSchema") + .asText()); + schema = this.schemaFactory.getSchema(schemaNode); + if (schema == null || schema.getSchemaNode().isMissingNode() || schema.getSchemaNode().isTextual()) { + throw new IOException(ORIGIN + "could not create valid schema for %s from %s " + .formatted(maybeTypePid, schemaNode)); + } + schema.initializeValidators(); + } catch (IOException e) { + return new SchemaInfo( + ORIGIN, + schema, + new ExternalServiceException(baseUrl.toString(), "No valid schema found resolving PID " + maybeTypePid, e) + ); + } + return new SchemaInfo(ORIGIN, schema, null); + } else if (status.value() == 404) { + return new SchemaInfo( + ORIGIN, + null, + new TypeNotFoundException(maybeTypePid) + ); + } else { + return new SchemaInfo( + ORIGIN, + null, + new ExternalServiceException( + this.baseUrl.toString(), + "Error generating schema: %s - %s".formatted(status.value(), status.toString())) + ); + } + }); + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaGenerator.java new file mode 100644 index 00000000..0e438f97 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaGenerator.java @@ -0,0 +1,12 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import edu.kit.datamanager.pit.common.ExternalServiceException; + +public interface SchemaGenerator { + /** + * Generates a schema for the given type. + * @param maybeTypePid the PID for the type to generate a schema for. + * @return the generated schema. + */ + SchemaInfo generateSchema(String maybeTypePid) throws ExternalServiceException; +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java new file mode 100644 index 00000000..ae96712e --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java @@ -0,0 +1,11 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import com.networknt.schema.JsonSchema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; + +public record SchemaInfo( + @NotNull String origin, + @Nullable JsonSchema schema, + @Nullable Throwable error +) {} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java new file mode 100644 index 00000000..59912555 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java @@ -0,0 +1,60 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import edu.kit.datamanager.pit.Application; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +public class SchemaSetGenerator { + private static final Logger LOGGER = LoggerFactory.getLogger(SchemaSetGenerator.class); + protected final Set GENERATORS; + protected final AsyncLoadingCache> CACHE; + + public SchemaSetGenerator(ApplicationProperties props) { + GENERATORS = Set.of( + new TypeApiSchemaGenerator(props), + new DtrTestSchemaGenerator(props) + ); + + CACHE = Caffeine.newBuilder() + .maximumSize(props.getCacheMaxEntries()) + .executor(Application.newExecutor()) + .refreshAfterWrite(Duration.ofMinutes(props.getCacheExpireAfterWriteLifetime() / 2)) + .expireAfterWrite(props.getCacheExpireAfterWriteLifetime(), TimeUnit.MINUTES) + .buildAsync(attributePid -> GENERATORS.stream() + .map(schemaGenerator -> schemaGenerator.generateSchema(attributePid)) + .peek(schemaInfo -> { + if (schemaInfo.error() != null) { + LOGGER.warn( + "Error when retrieving schema from {} for attribute ({}): {}", + schemaInfo.origin(), + attributePid, + schemaInfo.error().getMessage()); + } + } + ) + .collect(Collectors.toSet()) + ); + } + + /** + * Will generate a set of possible schemas for a given attribute PID and provide information about origin and success. + *

+ * Note that generation may fail and the schema may be null. In such cases, there will usually be error information + * available. + * + * @param attributePid the PID of the attribute to generate schemas for. + * @return a set of information about the generated schemas, including the schemas themselves, if generation succeeded. + */ + public CompletableFuture> generateFor(final String attributePid) { + return this.CACHE.get(attributePid); + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java new file mode 100644 index 00000000..76252c95 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java @@ -0,0 +1,99 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import edu.kit.datamanager.pit.Application; +import edu.kit.datamanager.pit.common.ExternalServiceException; +import edu.kit.datamanager.pit.common.InvalidConfigException; +import edu.kit.datamanager.pit.common.TypeNotFoundException; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import jakarta.validation.constraints.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; + +public class TypeApiSchemaGenerator implements SchemaGenerator { + private static final Logger LOG = LoggerFactory.getLogger(TypeApiSchemaGenerator.class); + + protected final URL baseUrl; + protected final RestClient http; + JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + + public TypeApiSchemaGenerator(@NotNull ApplicationProperties props) { + this.baseUrl = props.getTypeRegistryUri(); + String baseUri; + try { + baseUri = baseUrl.toURI().resolve("v1/types").toString(); + } catch (URISyntaxException e) { + throw new InvalidConfigException("Type-Api base url not valid: " + baseUrl, e); + } + this.http = RestClient.builder() + .baseUrl(baseUri) + .requestInterceptor((request, body, execution) -> { + long start = System.currentTimeMillis(); + ClientHttpResponse response = execution.execute(request, body); + long timeSpan = System.currentTimeMillis() - start; + boolean isLongRequest = timeSpan > Application.LONG_HTTP_REQUEST_THRESHOLD; + if (isLongRequest) { + LOG.warn("Long http request to {} ({}ms)", request.getURI(), timeSpan); + } + return response; + }) + .build(); + } + + @Override + public SchemaInfo generateSchema(@NotNull String maybeTypePid) { + return http.get() + .uri(uriBuilder -> uriBuilder + .pathSegment("schema") + .path(maybeTypePid) + .build()) + .exchange((request, response) -> { + HttpStatusCode statusCode = response.getStatusCode(); + if (statusCode.is2xxSuccessful()) { + JsonSchema schema = null; + try (InputStream inputStream = response.getBody()) { + JsonNode schemaDocument = Application.jsonObjectMapper() + .readTree(inputStream); + schema = schemaFactory.getSchema(schemaDocument); + if (schema == null || schema.getSchemaNode().isMissingNode() || schema.getSchemaNode().isTextual()) { + throw new IOException("Could not create valid schema for %s from %s " + .formatted(maybeTypePid, schemaDocument)); + } + schema.initializeValidators(); + } catch (IOException e) { + return new SchemaInfo( + this.baseUrl.toString(), + schema, + new ExternalServiceException( + baseUrl.toString(), + "Response (" + maybeTypePid + ") is not a valid schema.") + ); + } + return new SchemaInfo(this.baseUrl.toString(), schema, null); + } else if (statusCode.value() == 404) { + return new SchemaInfo( + this.baseUrl.toString(), + null, + new TypeNotFoundException(maybeTypePid)); + } else { + return new SchemaInfo( + this.baseUrl.toString(), + null, + new ExternalServiceException( + this.baseUrl.toString(), + "Error generating schema: %s - %s".formatted(statusCode.value(), response.getStatusText()))); + } + }); + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/util/TypeValidationUtils.java b/src/main/java/edu/kit/datamanager/pit/util/TypeValidationUtils.java deleted file mode 100644 index 5b062727..00000000 --- a/src/main/java/edu/kit/datamanager/pit/util/TypeValidationUtils.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package edu.kit.datamanager.pit.util; - -import edu.kit.datamanager.pit.common.RecordValidationException; -import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; - -import java.util.Collection; - -/** - * Utility class with static functions to validate PID records. - * - * @author Thomas Jejkal - */ -public class TypeValidationUtils { - - private TypeValidationUtils() {} - - /** - * Check if all mandatory attributes are present. If not, throw an exception. - * - * @param pidRecord the record to check for - * @param profile the profile to check against - * @throws RecordValidationException if at least one attribute is missing. It - * shows all missing attributes in its error - * message. - */ - public static void checkMandatoryAttributes(PIDRecord pidRecord, TypeDefinition profile) - throws RecordValidationException { - Collection missing = pidRecord.getMissingMandatoryTypesOf(profile); - if (!missing.isEmpty()) { - throw new RecordValidationException( - pidRecord, - "Missing mandatory types: " + missing); - } - } -} diff --git a/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java b/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java new file mode 100644 index 00000000..91c41be5 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.kit.datamanager.pit.web; + +import edu.kit.datamanager.pit.domain.PIDRecord; + +import java.util.List; +import java.util.Map; + +/** + * Response object for batch record operations. + * Contains a list of the processed PID records and a mapping of the user-provided "fictionary" identifiers to their corresponding real record Handle PIDs. + * This mapping was used to link the user-provided identifiers with the actual records in the system. + *

+ * + * @param pidRecords List of PIDRecord objects representing the processed records. (List) + * @param mapping Map where keys are user-provided identifiers (fictionary) and values are the corresponding real record Handle PIDs. (Map) + * @see PIDRecord + */ +public record BatchRecordResponse( + List pidRecords, + Map mapping) { +} diff --git a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java index e08e1169..b0a1985c 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java +++ b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 Karlsruhe Institute of Technology. + * Copyright (c) 2020-2025 Karlsruhe Institute of Technology. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,9 +15,9 @@ */ package edu.kit.datamanager.pit.web; -import edu.kit.datamanager.pit.pidlog.KnownPid; import edu.kit.datamanager.pit.domain.PIDRecord; import edu.kit.datamanager.pit.domain.SimplePidRecord; +import edu.kit.datamanager.pit.pidlog.KnownPid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -25,94 +25,121 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; - -import java.io.IOException; -import java.time.Instant; -import java.util.List; - +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; - import org.springdoc.core.converters.models.PageableAsQueryParam; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import org.springframework.web.context.request.WebRequest; import org.springframework.web.util.UriComponentsBuilder; -import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; /** - * * @author jejkal */ +@RestController +@RequestMapping(value = "/api/v1/pit") +@Schema(description = "PID Information Types API") +@Tag(name = "PID Management", description = "PID Information Types API") public interface ITypingRestResource { - /** - * Create a new PID using the record information provided in the request body. - * The record is expected to contain the identifier of the matching profile. - * Before creating the record, the record information will be validated against - * the profile. - * - * Important note: Validation caches recently used type information locally. - * Therefore, changes in a registry may take a few minutes to be reflected - * within the Typed PID Maker. This speeds up validation drastically in most - * situations. But it also means that, if the cache is empty, validation may - * take 30+ seconds. We are aware of the issue and considering improvements. But - * be aware that in general, validation may take up some time. - * - * @param rec The PID record. - * - * @return either 201 and a record representation, or an error (see ApiResponse - * annotations and tests). - * - * @throws IOException - */ @PostMapping( - path = "pid/", - consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, - produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + path = "pids", + consumes = {MediaType.APPLICATION_JSON_VALUE}, + produces = {MediaType.APPLICATION_JSON_VALUE} ) @Operation( - summary = "Create a new PID record", - description = "Create a new PID record using the record information from the request body." + summary = "Create a multiple, possibly related PID records", + description = "Create multiple, possibly related PID records using the record information. This endpoint is a convenience method to create multiple PID records at once. For connecting records, the PID fields must be specified and the value may be used in the value fields of other PIDRecordEntries. The provided PIDs will be overwritten as defined by the PID generator strategy.\n" + + "Note: This endpoint does not support custom PIDs, as the PID field is used for \"placeholder\" PIDs to connect records. These placeholder PIDs will be replaced by actual, resolvable PIDs as defined by the PID generator strategy. This goes for the PID referencing a record as well as references from other records, if they are provided as a single attribute value (i.e., not a JSON array within an attribute's value). If you want to create a record with custom PID suffixes, use the endpoint `POST /pid` and configure the Typed PID Maker accordingly." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "The body containing all PID record values as they should be in the new PIDs record.", - required = true, - content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - } + description = "The body containing a list of all PID record values as they should be in the new PID records. To connect records, the PID fields must be specified. This placeholder PID value may then be used in the value fields of other PID Record entries. During creation, these placeholder PIDs whose sole purpose is to connect records will be overwritten with actual, resolvable PIDs as defined by the PID generator strategy.", + required = true, + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))) + } ) @ApiResponses(value = { - @ApiResponse( - responseCode = "201", - description = "Created", + @ApiResponse( + responseCode = "201", + description = "Successfully created all records and resolved references (if they exist). The response contains the created records and the mapping used to map from the user-provided, placeholder PIDs to the actual Handle PIDs created in the process.", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = BatchRecordResponse.class)) + }), + @ApiResponse(responseCode = "400", description = "Validation failed. See body for details. Contains also the validated records.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "409", description = "If providing own PIDs is enabled 409 indicates, that the PID already exists.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + }) + ResponseEntity createPIDs( + @RequestBody final List rec, + + @Parameter(description = "If true, only validation will be done and no PIDs will be created. No data will be changed and no services will be notified.") + @RequestParam(name = "dryrun", required = false, defaultValue = "false") + boolean dryrun, + + final WebRequest request, + final HttpServletResponse response, + final UriComponentsBuilder uriBuilder + ) throws IOException; + + @PostMapping( + path = "pid/", + consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, + produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + ) + @Operation( + summary = "Create a new PID record", + description = "Create a new PID record using the record information from the request body." + + " The record may contain the identifier(s) of the matching profile(s)." + + " Before creating the record, the record information will be validated against" + + " the profile." + + " Validation takes some time, depending on the context. It depends a lot on the size" + + " of your record and the already cached information. This information is gathered" + + " from external services. If there are connection issues or hiccups at these sites," + + " validation may even take up to a few seconds. Usually you can expect the request" + + " to be between 100ms up to 1000ms on a fast machine with reliable connections." + ) + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "The body containing all PID record values as they should be in the new PIDs record.", + required = true, content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - }), - @ApiResponse(responseCode = "400", description = "Validation failed. See body for details. Contains also the validated record.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "409", description = "If providing an own PID is enabled 409 indicates, that the PID already exists.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + } + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Created", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + }), + @ApiResponse(responseCode = "400", description = "Validation failed. See body for details. Contains also the validated record.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "409", description = "If providing an own PID is enabled 409 indicates, that the PID already exists.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) }) - public ResponseEntity createPID( - @RequestBody - final PIDRecord rec, + ResponseEntity createPID( + @RequestBody final PIDRecord rec, @Parameter( description = "If true, only validation will be done" + " and no PID will be created. No data will be changed" + - " and no services will be notified.", - required = false + " and no services will be notified." ) @RequestParam(name = "dryrun", required = false, defaultValue = "false") boolean dryrun, @@ -122,65 +149,52 @@ public ResponseEntity createPID( final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Update the given PIDs record using the information provided in the request - * body. The record is expected to contain the identifier of the matching - * profile. Conditions for a valid record are the same as for creation. - *

- * Important note: Validation may take up to 30+ seconds. For details, see the - * documentation of "POST /pid/". - * - * @param rec the PID record. - * @param dryrun if only validation shall be executed. - * - * @return the record (on success). - * - * @throws IOException if the record could not be updated. - */ @PutMapping( - path = "pid/**", - consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, - produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + path = "pid/**", + consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, + produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} ) @Operation( - summary = "Update an existing PID record", - description = "Update an existing PID record using the record information from the request body." + summary = "Update an existing PID record", + description = "Update an existing PID record using the record information from the request body." + + " The record may contain the identifier(s) of the matching profiles. Conditions for a" + + " valid record are the same as for creation." + + " Important note: Validation may take some time. For details, see the documentation of" + + " \"POST /pid/\"." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "The body containing all PID record values as they should be after the update.", - required = true, - content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - } + description = "The body containing all PID record values as they should be after the update.", + required = true, + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + } ) @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Success.", - content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - }), - @ApiResponse(responseCode = "400", description = "Validation failed. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "412", description = "ETag comparison failed (Precondition failed)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "428", description = "No ETag given in If-Match header (Precondition required)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponse( + responseCode = "200", + description = "Success.", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + }), + @ApiResponse(responseCode = "400", description = "Validation failed. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "412", description = "ETag comparison failed (Precondition failed)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "428", description = "No ETag given in If-Match header (Precondition required)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) }) - public ResponseEntity updatePID( - @RequestBody - final PIDRecord rec, + ResponseEntity updatePID( + @RequestBody final PIDRecord rec, @Parameter( description = "If true, no PID will be updated. Only" + " validation checks are performed, and the expected" + " response, including the new eTag, will be returned." + " No data will be changed and no services will be" + - " notified.", - required = false + " notified." ) @RequestParam(name = "dryrun", required = false, defaultValue = "false") boolean dryrun, @@ -190,38 +204,34 @@ public ResponseEntity updatePID( final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Get the record of the given PID (or test if it exists). - * - * @return the record. - * - * @throws IOException - */ @GetMapping( - path = "pid/**", - produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + path = "pid/**", + produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + ) + @Operation( + summary = "Get the record of the given PID.", + description = "Get the record to the given PID, if it exists. May also be used to test" + + " if a PID exists. No validation is performed by default." ) - @Operation(summary = "Get the record of the given PID.", description = "Get the record to the given PID, if it exists. No validation is performed by default.") @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Found", - content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - } - ), - @ApiResponse(responseCode = "400", description = "Validation failed. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "404", description = "Not found", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponse( + responseCode = "200", + description = "Found", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + } + ), + @ApiResponse(responseCode = "400", description = "Validation failed. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "404", description = "Not found", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) }) - public ResponseEntity getRecord ( + ResponseEntity getRecord( @Parameter( description = "If true, validation will be run on the" + " resolved PID. On failure, an error will be" + - " returned. On success, the PID will be resolved.", - required = false + " returned. On success, the PID will be resolved." ) @RequestParam(name = "validation", required = false, defaultValue = "false") boolean validation, @@ -231,168 +241,125 @@ public ResponseEntity getRecord ( final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Requests a PID from the local store. If this PID is known, it will be - * returned together with the timestamps of creation and modification executed - * on this PID by this service. - * - * This store is not a cache! Instead, the service remembers every PID which it - * created (and resolved, depending on the configuration parameter - * `pit.storage.strategy` of the service) on request. - * - * @return the known PID and its timestamps. - * @throws IOException - */ @Operation( - summary = "Returns a PID and its timestamps from the local store, if available.", - description = "Returns a PID from the local store. This store is not a cache! Instead, the" + summary = "Returns a PID and its timestamps from the local store, if available.", + description = "Returns a PID from the local store. This store is not a cache! Instead, the" + " service remembers every PID which it created (and resolved, depending on the" + " configuration parameter `pit.storage.strategy` of the service) on request. If" + " this PID is known, it will be returned together with the timestamps of" + " creation and modification executed on this PID by this service.", - responses = { - @ApiResponse( - responseCode = "200", - description = "If the PID is known and its information was returned.", - content = @Content(schema = @Schema(implementation = KnownPid.class)) - ), - @ApiResponse( - responseCode = "404", - description = "If the PID is unknown.", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) - ), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) - } + responses = { + @ApiResponse( + responseCode = "200", + description = "If the PID is known and its information was returned.", + content = @Content(schema = @Schema(implementation = KnownPid.class)) + ), + @ApiResponse( + responseCode = "404", + description = "If the PID is unknown.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + } ) @GetMapping(path = "known-pid/**") - public ResponseEntity findByPid( + ResponseEntity findByPid( final WebRequest request, final HttpServletResponse response, final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Returns all known PIDs, limited by the given page size and number. - * Several filtering criteria are also available. - * - * Known PIDs are defined as being stored in a local store. This store is not a - * cache! Instead, the service remembers every PID which it created (and - * resolved, depending on the configuration parameter `pit.storage.strategy` of - * the service) on request. - * - * @param createdAfter defines the earliest date for the creation timestamp. - * @param createdBefore defines the latest date for the creation timestamp. - * @param modifiedAfter defines the earliest date for the modification - * timestamp. - * @param modifiedBefore defines the latest date for the modification timestamp. - * @param pageable defines page size and page to navigate through large - * lists. - * @return the PIDs matching all given contraints. - */ @Operation( - summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", - description = "Returns all known PIDs, limited by the given page size and number. " - + "Several filtering criteria are also available. Known PIDs are defined as " - + "being stored in a local store. This store is not a cache! Instead, the " - + "service remembers every PID which it created (and resolved, depending on " - + "the configuration parameter `pit.storage.strategy` of the service) on " - + "request. Use the Accept header to adjust the format.", - responses = { - @ApiResponse( - responseCode = "200", - description = "If the request was valid. May return an empty list.", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = KnownPid.class))) - ), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) - } + summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", + description = "Returns all known PIDs, limited by the given page size and number. " + + "Several filtering criteria are also available. Known PIDs are defined as " + + "being stored in a local store. This store is not a cache! Instead, the " + + "service remembers every PID which it created (and resolved, depending on " + + "the configuration parameter `pit.storage.strategy` of the service) on " + + "request. Use the Accept header to adjust the format.", + responses = { + @ApiResponse( + responseCode = "200", + description = "If the request was valid. May return an empty list.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = KnownPid.class))) + ), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + } ) @GetMapping(path = "known-pid") @PageableAsQueryParam - public ResponseEntity> findAll( - @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.", required = false) + ResponseEntity> findAll( + @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.") @RequestParam(name = "created_after", required = false) Instant createdAfter, - @Parameter(name = "created_before", description = "The UTC time of the latest creation timestamp of a returned PID.", required = false) + @Parameter(name = "created_before", description = "The UTC time of the latest creation timestamp of a returned PID.") @RequestParam(name = "created_before", required = false) Instant createdBefore, - @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.", required = false) + @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.") @RequestParam(name = "modified_after", required = false) Instant modifiedAfter, - - @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.", required = false) + + @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.") @RequestParam(name = "modified_before", required = false) Instant modifiedBefore, @Parameter(hidden = true) @PageableDefault(sort = {"modified"}, direction = Sort.Direction.ASC) Pageable pageable, - + WebRequest request, - + HttpServletResponse response, - + UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Like findAll, but the return value is formatted for the tabulator - * javascript library. - * - * @param createdAfter defines the earliest date for the creation timestamp. - * @param createdBefore defines the latest date for the creation timestamp. - * @param modifiedAfter defines the earliest date for the modification - * timestamp. - * @param modifiedBefore defines the latest date for the modification timestamp. - * @param pageable defines page size and page to navigate through large - * lists. - * @return the PIDs matching all given contraints. - */ - @Operation( - summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", - description = "Returns all known PIDs, limited by the given page size and number. " - + "Several filtering criteria are also available. Known PIDs are defined as " - + "being stored in a local store. This store is not a cache! Instead, the " - + "service remembers every PID which it created (and resolved, depending on " - + "the configuration parameter `pit.storage.strategy` of the service) on " - + "request. Use the Accept header to adjust the format.", - responses = { - @ApiResponse( - responseCode = "200", - description = "If the request was valid. May return an empty list.", - content = @Content(schema = @Schema(implementation = TabulatorPaginationFormat.class)) - ), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) - } + @Operation( + summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", + description = "Returns all known PIDs, limited by the given page size and number. " + + "Several filtering criteria are also available. Known PIDs are defined as " + + "being stored in a local store. This store is not a cache! Instead, the " + + "service remembers every PID which it created (and resolved, depending on " + + "the configuration parameter `pit.storage.strategy` of the service) on " + + "request. Use the Accept header to adjust the format.", + responses = { + @ApiResponse( + responseCode = "200", + description = "If the request was valid. May return an empty list.", + content = @Content(schema = @Schema(implementation = TabulatorPaginationFormat.class)) + ), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + } ) - @GetMapping(path = "known-pid", produces={"application/tabulator+json"}, headers = "Accept=application/tabulator+json") + @GetMapping(path = "known-pid", produces = {"application/tabulator+json"}, headers = "Accept=application/tabulator+json") @PageableAsQueryParam - public ResponseEntity> findAllForTabular( - @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.", required = false) + ResponseEntity> findAllForTabular( + @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.") @RequestParam(name = "created_after", required = false) Instant createdAfter, - @Parameter(name = "created_before", description = "The UTC time of the latest creation timestamp of a returned PID.", required = false) + @Parameter(name = "created_before", description = "The UTC time of the latest creation timestamp of a returned PID.") @RequestParam(name = "created_before", required = false) Instant createdBefore, - @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.", required = false) + @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.") @RequestParam(name = "modified_after", required = false) Instant modifiedAfter, - - @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.", required = false) + + @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.") @RequestParam(name = "modified_before", required = false) Instant modifiedBefore, @Parameter(hidden = true) @PageableDefault(sort = {"modified"}, direction = Sort.Direction.ASC) Pageable pageable, - + WebRequest request, - + HttpServletResponse response, - + UriComponentsBuilder uriBuilder ) throws IOException; } \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 99dcef10..88a99f94 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -1,378 +1,561 @@ -package edu.kit.datamanager.pit.web.impl; - -import edu.kit.datamanager.exceptions.CustomInternalServerError; -import java.io.IOException; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; - -import edu.kit.datamanager.pit.common.PidAlreadyExistsException; -import edu.kit.datamanager.pit.common.PidNotFoundException; -import edu.kit.datamanager.pit.configuration.ApplicationProperties; -import edu.kit.datamanager.pit.configuration.PidGenerationProperties; -import edu.kit.datamanager.pit.common.RecordValidationException; -import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.elasticsearch.PidRecordElasticRepository; -import edu.kit.datamanager.pit.elasticsearch.PidRecordElasticWrapper; -import edu.kit.datamanager.pit.pidgeneration.PidSuffix; -import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; -import edu.kit.datamanager.pit.pidlog.KnownPid; -import edu.kit.datamanager.pit.pidlog.KnownPidsDao; -import edu.kit.datamanager.pit.pitservice.ITypingService; -import edu.kit.datamanager.pit.web.ITypingRestResource; -import edu.kit.datamanager.pit.web.TabulatorPaginationFormat; -import edu.kit.datamanager.service.IMessagingService; -import edu.kit.datamanager.entities.messaging.PidRecordMessage; -import edu.kit.datamanager.util.AuthenticationHelper; -import edu.kit.datamanager.util.ControllerUtils; -import io.swagger.v3.oas.annotations.media.Schema; - -import jakarta.servlet.http.HttpServletResponse; - -import org.apache.commons.lang3.stream.Streams; -import org.apache.http.client.cache.HeaderConstants; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.WebRequest; -import org.springframework.web.servlet.HandlerMapping; -import org.springframework.web.util.UriComponentsBuilder; - -@RestController -@RequestMapping(value = "/api/v1/pit") -@Schema(description = "PID Information Types API") -public class TypingRESTResourceImpl implements ITypingRestResource { - - private static final Logger LOG = LoggerFactory.getLogger(TypingRESTResourceImpl.class); - - @Autowired - private ApplicationProperties applicationProps; - - @Autowired - protected ITypingService typingService; - - @Autowired - private IMessagingService messagingService; - - @Autowired - private KnownPidsDao localPidStorage; - - @Autowired - private Optional elastic; - - @Autowired - private PidSuffixGenerator suffixGenerator; - - @Autowired - private PidGenerationProperties pidGenerationProperties; - - public TypingRESTResourceImpl() { - super(); - } - - @Override - public ResponseEntity createPID( - PIDRecord pidRecord, - boolean dryrun, - - final WebRequest request, - final HttpServletResponse response, - final UriComponentsBuilder uriBuilder - ) throws IOException { - LOG.info("Creating PID"); - - if (dryrun) { - pidRecord.setPid("dryrun"); - } else { - setPid(pidRecord); - } - - this.typingService.validate(pidRecord); - - if (dryrun) { - // dryrun only does validation. Stop now and return as we would later on. - return ResponseEntity.status(HttpStatus.OK).eTag(quotedEtag(pidRecord)).body(pidRecord); - } - - String pid = this.typingService.registerPID(pidRecord); - pidRecord.setPid(pid); - - if (applicationProps.getStorageStrategy().storesModified()) { - storeLocally(pid, true); - } - PidRecordMessage message = PidRecordMessage.creation( - pid, - "", // TODO parameter is depricated and will be removed soon. - AuthenticationHelper.getPrincipal(), - ControllerUtils.getLocalHostname()); - try { - this.messagingService.send(message); - } catch (Exception e) { - LOG.error("Could not notify messaging service about the following message: {}", message); - } - this.saveToElastic(pidRecord); - return ResponseEntity.status(HttpStatus.CREATED).eTag(quotedEtag(pidRecord)).body(pidRecord); - } - - private boolean hasPid(PIDRecord pidRecord) { - return pidRecord.getPid() != null && !pidRecord.getPid().isBlank(); - } - - private void setPid(PIDRecord pidRecord) throws IOException { - boolean hasCustomPid = hasPid(pidRecord); - boolean allowsCustomPids = pidGenerationProperties.isCustomClientPidsEnabled(); - - if (allowsCustomPids && hasCustomPid) { - // in this only case, we do not have to generate a PID - // but we have to check if the PID is already registered and return an error if so - String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); - String maybeSuffix = pidRecord.getPid(); - String pid = PidSuffix.asPrefixedChecked(maybeSuffix, prefix); - boolean isRegisteredPid = this.typingService.isIdentifierRegistered(pid); - if (isRegisteredPid) { - throw new PidAlreadyExistsException(pidRecord.getPid()); - } - } else { - // In all other (usual) cases, we have to generate a PID. - // We store only the suffix in the pid field. - // The registration at the PID service will preprend the prefix. - - Stream suffixStream = suffixGenerator.infiniteStream(); - Optional maybeSuffix = Streams.failableStream(suffixStream) - // With failible streams, we can throw exceptions. - .filter(suffix -> !this.typingService.isIdentifierRegistered(suffix)) - .stream() // back to normal java streams - .findFirst(); // as the stream is infinite, we should always find a prefix. - PidSuffix suffix = maybeSuffix.orElseThrow(() -> new IOException("Could not generate PID suffix.")); - pidRecord.setPid(suffix.get()); - } - } - - @Override - public ResponseEntity updatePID( - PIDRecord pidRecord, - boolean dryrun, - - final WebRequest request, - final HttpServletResponse response, - final UriComponentsBuilder uriBuilder - ) throws IOException { - // PID validation - String pid = getContentPathFromRequest("pid", request); - String pidInternal = pidRecord.getPid(); - if (hasPid(pidRecord) && !pid.equals(pidInternal)) { - throw new RecordValidationException( - pidRecord, - "Optional PID in record is given (%s), but it was not the same as the PID in the URL (%s). Ignore request, assuming this was not intended.".formatted(pidInternal, pid)); - } - - PIDRecord existingRecord = this.typingService.queryAllProperties(pid); - if (existingRecord == null) { - throw new PidNotFoundException(pid); - } - - // record validation - pidRecord.setPid(pid); - this.typingService.validate(pidRecord); - - // throws exception (HTTP 412) if check fails. - ControllerUtils.checkEtag(request, existingRecord); - - if (dryrun) { - // dryrun only does validation. Stop now and return as we would later on. - return ResponseEntity.ok().eTag(quotedEtag(pidRecord)).body(pidRecord); - } - - // update and send message - if (this.typingService.updatePID(pidRecord)) { - // store pid locally - if (applicationProps.getStorageStrategy().storesModified()) { - storeLocally(pidRecord.getPid(), true); - } - // distribute pid to other services - PidRecordMessage message = PidRecordMessage.update( - pid, - "", // TODO parameter is depricated and will be removed soon. - AuthenticationHelper.getPrincipal(), - ControllerUtils.getLocalHostname()); - this.messagingService.send(message); - this.saveToElastic(pidRecord); - return ResponseEntity.ok().eTag(quotedEtag(pidRecord)).body(pidRecord); - } else { - throw new PidNotFoundException(pid); - } - } - - /** - * Stores the PID in a local database. - * - * @param pid the PID - * @param update if true, updates the modified timestamp if it already exists. - * If it does not exist, it will be created with both timestamps - * (created and modified) being the same. - */ - private void storeLocally(String pid, boolean update) { - Instant now = Instant.now(); - Optional oldPid = localPidStorage.findByPid(pid); - if (oldPid.isEmpty()) { - localPidStorage.saveAndFlush(new KnownPid(pid, now, now)); - } else if (update) { - KnownPid newPid = oldPid.get(); - newPid.setModified(now); - localPidStorage.saveAndFlush(newPid); - } - } - - private String getContentPathFromRequest(String lastPathElement, WebRequest request) { - String requestedUri = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, - RequestAttributes.SCOPE_REQUEST); - if (requestedUri == null) { - throw new CustomInternalServerError("Unable to obtain request URI."); - } - return requestedUri.substring(requestedUri.indexOf(lastPathElement + "/") + (lastPathElement + "/").length()); - } - - @Override - public ResponseEntity getRecord( - boolean validation, - - final WebRequest request, - final HttpServletResponse response, - final UriComponentsBuilder uriBuilder - ) throws IOException { - String pid = getContentPathFromRequest("pid", request); - PIDRecord pidRecord = this.typingService.queryAllProperties(pid); - if (applicationProps.getStorageStrategy().storesResolved()) { - storeLocally(pid, false); - } - this.saveToElastic(pidRecord); - if (validation) { - typingService.validate(pidRecord); - } - return ResponseEntity.ok().eTag(quotedEtag(pidRecord)).body(pidRecord); - } - - private void saveToElastic(PIDRecord rec) { - this.elastic.ifPresent( - database -> database.save( - new PidRecordElasticWrapper(rec, typingService.getOperations()) - ) - ); - } - - @Override - public ResponseEntity findByPid( - WebRequest request, - HttpServletResponse response, - UriComponentsBuilder uriBuilder - ) throws IOException { - String pid = getContentPathFromRequest("known-pid", request); - Optional known = this.localPidStorage.findByPid(pid); - if (known.isPresent()) { - return ResponseEntity.ok().body(known.get()); - } - return ResponseEntity.notFound().build(); - } - - public Page findAllPage( - Instant createdAfter, - Instant createdBefore, - Instant modifiedAfter, - Instant modifiedBefore, - Pageable pageable - ) { - final boolean queriesCreated = createdAfter != null || createdBefore != null; - final boolean queriesModified = modifiedAfter != null || modifiedBefore != null; - if (queriesCreated && createdAfter == null) { - createdAfter = Instant.EPOCH; - } - if (queriesCreated && createdBefore == null) { - createdBefore = Instant.now().plus(1, ChronoUnit.DAYS); - } - if (queriesModified && modifiedAfter == null) { - modifiedAfter = Instant.EPOCH; - } - if (queriesModified && modifiedBefore == null) { - modifiedBefore = Instant.now().plus(1, ChronoUnit.DAYS); - } - - Page resultCreatedTimestamp = Page.empty(); - Page resultModifiedTimestamp = Page.empty(); - if (queriesCreated) { - resultCreatedTimestamp = this.localPidStorage - .findDistinctPidsByCreatedBetween(createdAfter, createdBefore, pageable); - } - if (queriesModified) { - resultModifiedTimestamp = this.localPidStorage - .findDistinctPidsByModifiedBetween(modifiedAfter, modifiedBefore, pageable); - } - if (queriesCreated && queriesModified) { - final Page tmp = resultModifiedTimestamp; - final List intersection = resultCreatedTimestamp.filter((x) -> tmp.getContent().contains(x)).toList(); - return new PageImpl<>(intersection); - } else if (queriesCreated) { - return resultCreatedTimestamp; - } else if (queriesModified) { - return resultModifiedTimestamp; - } - return new PageImpl<>(this.localPidStorage.findAll()); - } - - @Override - public ResponseEntity> findAll( - Instant createdAfter, - Instant createdBefore, - Instant modifiedAfter, - Instant modifiedBefore, - Pageable pageable, - WebRequest request, - HttpServletResponse response, - UriComponentsBuilder uriBuilder) throws IOException - { - Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); - response.addHeader( - HeaderConstants.CONTENT_RANGE, - ControllerUtils.getContentRangeHeader( - page.getNumber(), - page.getSize(), - page.getTotalElements())); - return ResponseEntity.ok().body(page.getContent()); - } - - @Override - public ResponseEntity> findAllForTabular( - Instant createdAfter, - Instant createdBefore, - Instant modifiedAfter, - Instant modifiedBefore, - Pageable pageable, - WebRequest request, - HttpServletResponse response, - UriComponentsBuilder uriBuilder) throws IOException - { - Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); - response.addHeader( - HeaderConstants.CONTENT_RANGE, - ControllerUtils.getContentRangeHeader( - page.getNumber(), - page.getSize(), - page.getTotalElements())); - TabulatorPaginationFormat tabPage = new TabulatorPaginationFormat<>(page); - return ResponseEntity.ok().body(tabPage); - } - - private String quotedEtag(PIDRecord pidRecord) { - return String.format("\"%s\"", pidRecord.getEtag()); - } - -} +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.kit.datamanager.pit.web.impl; + +import edu.kit.datamanager.entities.messaging.PidRecordMessage; +import edu.kit.datamanager.exceptions.CustomInternalServerError; +import edu.kit.datamanager.pit.common.*; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.configuration.PidGenerationProperties; +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.elasticsearch.PidRecordElasticRepository; +import edu.kit.datamanager.pit.elasticsearch.PidRecordElasticWrapper; +import edu.kit.datamanager.pit.pidgeneration.PidSuffix; +import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; +import edu.kit.datamanager.pit.pidlog.KnownPid; +import edu.kit.datamanager.pit.pidlog.KnownPidsDao; +import edu.kit.datamanager.pit.pitservice.ITypingService; +import edu.kit.datamanager.pit.resolver.Resolver; +import edu.kit.datamanager.pit.web.BatchRecordResponse; +import edu.kit.datamanager.pit.web.ITypingRestResource; +import edu.kit.datamanager.pit.web.TabulatorPaginationFormat; +import edu.kit.datamanager.service.IMessagingService; +import edu.kit.datamanager.util.AuthenticationHelper; +import edu.kit.datamanager.util.ControllerUtils; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.stream.Streams; +import org.apache.http.client.cache.HeaderConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Stream; + +@RestController +public class TypingRESTResourceImpl implements ITypingRestResource { + + private static final Logger LOG = LoggerFactory.getLogger(TypingRESTResourceImpl.class); + + private final ITypingService typingService; + private final Resolver resolver; + private final ApplicationProperties applicationProps; + private final IMessagingService messagingService; + private final KnownPidsDao localPidStorage; + private final Optional elastic; + private final PidSuffixGenerator suffixGenerator; + private final PidGenerationProperties pidGenerationProperties; + + public TypingRESTResourceImpl(ITypingService typingService, Resolver resolver, ApplicationProperties applicationProps, IMessagingService messagingService, KnownPidsDao localPidStorage, Optional elastic, PidSuffixGenerator suffixGenerator, PidGenerationProperties pidGenerationProperties) { + super(); + this.typingService = typingService; + this.resolver = resolver; + this.applicationProps = applicationProps; + this.messagingService = messagingService; + this.localPidStorage = localPidStorage; + this.elastic = elastic; + this.suffixGenerator = suffixGenerator; + this.pidGenerationProperties = pidGenerationProperties; + } + + @Override + public ResponseEntity createPIDs( + List rec, + boolean dryrun, + WebRequest request, + HttpServletResponse response, + UriComponentsBuilder uriBuilder + ) throws IOException, RecordValidationException, ExternalServiceException { + if (rec == null || rec.isEmpty()) { + LOG.warn("No records provided for PID creation."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new BatchRecordResponse(Collections.emptyList(), Collections.emptyMap())); + } + if (rec.size() == 1) { + // If only one record is provided, we can use the single record creation method. + LOG.info("Only one record provided. Using single record creation method."); + var result = createPID(rec.getFirst(), dryrun, request, response, uriBuilder); + // Return the single record in a list + assert result.getBody() != null; + return ResponseEntity.status(result.getStatusCode()).headers(result.getHeaders()).body(new BatchRecordResponse(Collections.singletonList(result.getBody()), Collections.singletonMap(rec.getFirst().getPid(), result.getBody().getPid()))); + } + Instant startTime = Instant.now(); + LOG.info("Creating PIDs for {} records.", rec.size()); + String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); + + // Generate a map between temporary (user-defined) PIDs and final PIDs (generated) + Map pidMappings = generatePIDMapping(rec, dryrun); + Instant mappingTime = Instant.now(); + + // Apply the mappings to the records and validate them + List validatedRecords = applyMappingsToRecordsAndValidate(rec, pidMappings, prefix); + Instant validationTime = Instant.now(); + + if (dryrun) { + // dryrun only does validation. Stop now and return as we would later on. + LOG.info("Time taken for dryrun: {} ms", ChronoUnit.MILLIS.between(startTime, validationTime)); + LOG.info("-- Time taken for mapping: {} ms", ChronoUnit.MILLIS.between(startTime, mappingTime)); + LOG.info("-- Time taken for validation: {} ms", ChronoUnit.MILLIS.between(mappingTime, validationTime)); + LOG.info("Dryrun finished. Returning validated records for {} records.", validatedRecords.size()); + addPrefixToMapping(pidMappings, prefix); + return ResponseEntity.status(HttpStatus.OK).body(new BatchRecordResponse(validatedRecords, pidMappings)); + } + + List failedRecords = new ArrayList<>(); + List successfulRecords = new ArrayList<>(); + // register the records + validatedRecords.forEach(pidRecord -> { + try { + // register the PID + String pid = this.typingService.registerPid(pidRecord); + pidRecord.setPid(pid); + + // store pid locally in accordance with the storage strategy + if (applicationProps.getStorageStrategy().storesModified()) { + storeLocally(pid, true); + } + + // distribute pid creation event to other services + PidRecordMessage message = PidRecordMessage.creation( + pid, + "", // TODO parameter is deprecated and will be removed soon. + AuthenticationHelper.getPrincipal(), + ControllerUtils.getLocalHostname()); + try { + this.messagingService.send(message); + } catch (Exception e) { + LOG.error("Could not notify messaging service about the following message: {}", message); + } + + // save the record to elastic + this.saveToElastic(pidRecord); + successfulRecords.add(pidRecord); + LOG.debug("Successfully registered PID for record: {}", pidRecord); + } catch (Exception e) { + LOG.error("Could not register PID for record {}. Error: {}", pidRecord, e.getMessage()); + failedRecords.add(pidRecord); + } + }); + + Instant endTime = Instant.now(); + + // return the created records + LOG.info("Total time taken: {} ms", ChronoUnit.MILLIS.between(startTime, endTime)); + LOG.info("-- Time taken for mapping: {} ms", ChronoUnit.MILLIS.between(startTime, mappingTime)); + LOG.info("-- Time taken for validation: {} ms", ChronoUnit.MILLIS.between(mappingTime, validationTime)); + LOG.info("-- Time taken for registration: {} ms", ChronoUnit.MILLIS.between(validationTime, endTime)); + + if (!failedRecords.isEmpty()) { + List rollbackFailures = new ArrayList<>(); + for (PIDRecord successfulRecord : successfulRecords) { // rollback the successful records + try { + LOG.debug("Rolling back PID creation for record with PID {}.", successfulRecord.getPid()); + this.typingService.deletePid(successfulRecord.getPid()); + } catch (Exception e) { + rollbackFailures.add(successfulRecord.getPid()); + LOG.error("Could not rollback PID creation for record with PID {}. Error: {}", successfulRecord.getPid(), e.getMessage()); + } + } + + if (!rollbackFailures.isEmpty()) { + LOG.error("Failed to rollback {} PIDs: {}", rollbackFailures.size(), rollbackFailures); + } + + LOG.info("Creation finished. Returning validated records for {} records. {} records failed to be created.", validatedRecords.size(), failedRecords.size()); + addPrefixToMapping(pidMappings, prefix); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new BatchRecordResponse(failedRecords, pidMappings)); + } else { + LOG.info("Creation finished. Returning successfully validated and created records for {} records of {}.", successfulRecords.size(), validatedRecords.size()); + addPrefixToMapping(pidMappings, prefix); + return ResponseEntity.status(HttpStatus.CREATED).body(new BatchRecordResponse(successfulRecords, pidMappings)); + } + } + + private static void addPrefixToMapping(Map pidMappings, String prefix) { + pidMappings.replaceAll((placeholder, realSuffix) -> prefix + realSuffix); + } + + /** + * This method generates a mapping between user-provided "fantasy" PIDs and real PIDs. + * + * @param rec the list of records produced by the user + * @param dryrun whether the operation is a dryrun or not + * @return a map between the user-provided PIDs (key) and the real PIDs (values) + * @throws RecordValidationException if the same internal PID is used for multiple records + * @throws ExternalServiceException if the PID generation fails + */ + private Map generatePIDMapping(List rec, boolean dryrun) throws RecordValidationException, ExternalServiceException { + Map pidMappings = new HashMap<>(); + for (PIDRecord pidRecord : rec) { + String internalPID = pidRecord.getPid(); // the internal PID is the one given by the user + if (internalPID == null) { + internalPID = ""; // if no PID was given, we set it to an empty string + } + if (!internalPID.isBlank() && pidMappings.containsKey(internalPID)) { // check if the internal PID was already used + // This internal PID was already used by some other record in the same request. + throw new RecordValidationException(pidRecord, "The PID " + internalPID + " was used for multiple records in the same request."); + } + + pidRecord.setPid(""); // clear the PID field in the record + if (dryrun) { // if it is a dryrun, we set the PID to a temporary value + pidRecord.setPid("dryrun_" + pidMappings.size()); + } else { + setPid(pidRecord); // otherwise, we generate a real PID + } + pidMappings.put(internalPID, pidRecord.getPid()); // store the mapping between the internal and real PID + } + return pidMappings; + } + + /** + * This method applies the mappings between temporary PIDs and real PIDs to the records and validates them. + * + * @param rec the list of records produced by the user + * @param pidMappings the map between the user-provided PIDs (key) and the real PIDs (values) + * @param prefix the prefix to be used for the real PIDs + * @return the list of validated records + * @throws RecordValidationException as a possible validation outcome + * @throws ExternalServiceException as a possible validation outcome + */ + private List applyMappingsToRecordsAndValidate(List rec, Map pidMappings, String prefix) throws RecordValidationException, ExternalServiceException { + List validatedRecords = new ArrayList<>(); + for (PIDRecord pidRecord : rec) { + + // use this map to replace all temporary PIDs in the record values with their corresponding real PIDs + pidRecord.getEntries().values().stream() // get all values of the record + .flatMap(List::stream) // flatten the list of values + .filter(entry -> entry.getValue() != null) // Filter out null values + .filter(entry -> pidMappings.containsKey(entry.getValue())) // replace only if the value (aka. "fantasy PID") is a key in the map + .peek(entry -> LOG.debug("Found reference. Replacing {} with {}.", entry.getValue(), prefix + pidMappings.get(entry.getValue()))) // log the replacement + .forEach(entry -> entry.setValue(prefix + pidMappings.get(entry.getValue()))); // replace the value with the real PID according to the map + + // validate the record + this.typingService.validate(pidRecord); + + // store the record + validatedRecords.add(pidRecord); + LOG.debug("Record {} is valid.", pidRecord); + } + return validatedRecords; + } + + @Override + public ResponseEntity createPID( + PIDRecord pidRecord, + boolean dryrun, + + final WebRequest request, + final HttpServletResponse response, + final UriComponentsBuilder uriBuilder + ) { + LOG.info("Creating PID"); + + if (dryrun) { + pidRecord.setPid("dryrun"); + } else { + setPid(pidRecord); + } + + this.typingService.validate(pidRecord); + + if (dryrun) { + // dryrun only does validation. Stop now and return as we would later on. + return ResponseEntity.status(HttpStatus.OK).eTag(quotedEtag(pidRecord)).body(pidRecord); + } + + String pid = this.typingService.registerPid(pidRecord); + pidRecord.setPid(pid); + + if (applicationProps.getStorageStrategy().storesModified()) { + storeLocally(pid, true); + } + PidRecordMessage message = PidRecordMessage.creation( + pid, + "", // TODO parameter is deprecated and will be removed soon. + AuthenticationHelper.getPrincipal(), + ControllerUtils.getLocalHostname()); + try { + this.messagingService.send(message); + } catch (Exception e) { + LOG.error("Could not notify messaging service about the following message: {}", message); + } + this.saveToElastic(pidRecord); + return ResponseEntity.status(HttpStatus.CREATED).eTag(quotedEtag(pidRecord)).body(pidRecord); + } + + private boolean hasPid(PIDRecord pidRecord) { + return pidRecord.getPid() != null && !pidRecord.getPid().isBlank(); + } + + private void setPid(PIDRecord pidRecord) { + boolean hasCustomPid = hasPid(pidRecord); + boolean allowsCustomPids = pidGenerationProperties.isCustomClientPidsEnabled(); + + if (allowsCustomPids && hasCustomPid) { + // in this only case, we do not have to generate a PID + // but we have to check if the PID is already registered and return an error if so + String prefix = this.typingService.getPrefix() + .orElseThrow(() -> new InvalidConfigException("No prefix configured.")); + String maybeSuffix = pidRecord.getPid(); + String pid = PidSuffix.asPrefixedChecked(maybeSuffix, prefix); + boolean isRegisteredPid = this.typingService.isPidRegistered(pid); + if (isRegisteredPid) { + throw new PidAlreadyExistsException(pidRecord.getPid()); + } + } else { + // In all other (usual) cases, we have to generate a PID. + // We store only the suffix in the pid field. + // The registration at the PID service will preprend the prefix. + + Stream suffixStream = suffixGenerator.infiniteStream(); + Optional maybeSuffix = Streams.failableStream(suffixStream) + // With failable streams, we can throw exceptions. + .filter(suffix -> !this.typingService.isPidRegistered(suffix)) + .stream() // back to normal java streams + .findFirst(); // as the stream is infinite, we should always find a prefix. + PidSuffix suffix = maybeSuffix + .orElseThrow(() -> new ExternalServiceException("Could not generate PID suffix which did not exist yet.")); + pidRecord.setPid(suffix.get()); + } + } + + @Override + public ResponseEntity updatePID( + PIDRecord pidRecord, + boolean dryrun, + + final WebRequest request, + final HttpServletResponse response, + final UriComponentsBuilder uriBuilder + ) { + // PID validation + String pid = getContentPathFromRequest("pid", request); + String pidInternal = pidRecord.getPid(); + if (hasPid(pidRecord) && !pid.equals(pidInternal)) { + throw new RecordValidationException( + pidRecord, + "Optional PID in record is given (%s), but it was not the same as the PID in the URL (%s). Ignore request, assuming this was not intended.".formatted(pidInternal, pid)); + } + + PIDRecord existingRecord = this.resolver.resolve(pid); + if (existingRecord == null) { + throw new PidNotFoundException(pid); + } + + // record validation + pidRecord.setPid(pid); + this.typingService.validate(pidRecord); + + // throws exception (HTTP 412) if check fails. + ControllerUtils.checkEtag(request, existingRecord); + + if (dryrun) { + // dryrun only does validation. Stop now and return as we would later on. + return ResponseEntity.ok().eTag(quotedEtag(pidRecord)).body(pidRecord); + } + + // update and send message + if (this.typingService.updatePid(pidRecord)) { + // store pid locally + if (applicationProps.getStorageStrategy().storesModified()) { + storeLocally(pidRecord.getPid(), true); + } + // distribute pid to other services + PidRecordMessage message = PidRecordMessage.update( + pid, + "", // TODO parameter is depricated and will be removed soon. + AuthenticationHelper.getPrincipal(), + ControllerUtils.getLocalHostname()); + this.messagingService.send(message); + this.saveToElastic(pidRecord); + return ResponseEntity.ok().eTag(quotedEtag(pidRecord)).body(pidRecord); + } else { + throw new PidNotFoundException(pid); + } + } + + /** + * Stores the PID in a local database. + * + * @param pid the PID + * @param update if true, updates the modified timestamp if it already exists. + * If it does not exist, it will be created with both timestamps + * (created and modified) being the same. + */ + private void storeLocally(String pid, boolean update) { + Instant now = Instant.now(); + Optional oldPid = localPidStorage.findByPid(pid); + if (oldPid.isEmpty()) { + localPidStorage.saveAndFlush(new KnownPid(pid, now, now)); + } else if (update) { + KnownPid newPid = oldPid.get(); + newPid.setModified(now); + localPidStorage.saveAndFlush(newPid); + } + } + + private String getContentPathFromRequest(String lastPathElement, WebRequest request) { + String requestedUri = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, + RequestAttributes.SCOPE_REQUEST); + if (requestedUri == null) { + throw new CustomInternalServerError("Unable to obtain request URI."); + } + return requestedUri.substring(requestedUri.indexOf(lastPathElement + "/") + (lastPathElement + "/").length()); + } + + @Override + public ResponseEntity getRecord( + boolean validation, + + final WebRequest request, + final HttpServletResponse response, + final UriComponentsBuilder uriBuilder + ) { + String pid = getContentPathFromRequest("pid", request); + PIDRecord pidRecord = this.resolver.resolve(pid); + if (applicationProps.getStorageStrategy().storesResolved()) { + storeLocally(pid, false); + } + this.saveToElastic(pidRecord); + if (validation) { + typingService.validate(pidRecord); + } + return ResponseEntity.ok().eTag(quotedEtag(pidRecord)).body(pidRecord); + } + + private void saveToElastic(PIDRecord rec) { + this.elastic.ifPresent( + database -> database.save( + new PidRecordElasticWrapper(rec, typingService.getOperations()) + ) + ); + } + + @Override + public ResponseEntity findByPid( + WebRequest request, + HttpServletResponse response, + UriComponentsBuilder uriBuilder + ) throws IOException { + String pid = getContentPathFromRequest("known-pid", request); + Optional known = this.localPidStorage.findByPid(pid); + return known + .map(knownPid -> ResponseEntity.ok().body(knownPid)) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + public Page findAllPage( + Instant createdAfter, + Instant createdBefore, + Instant modifiedAfter, + Instant modifiedBefore, + Pageable pageable + ) { + final boolean queriesCreated = createdAfter != null || createdBefore != null; + final boolean queriesModified = modifiedAfter != null || modifiedBefore != null; + if (queriesCreated && createdAfter == null) { + createdAfter = Instant.EPOCH; + } + if (queriesCreated && createdBefore == null) { + createdBefore = Instant.now().plus(1, ChronoUnit.DAYS); + } + if (queriesModified && modifiedAfter == null) { + modifiedAfter = Instant.EPOCH; + } + if (queriesModified && modifiedBefore == null) { + modifiedBefore = Instant.now().plus(1, ChronoUnit.DAYS); + } + + Page resultCreatedTimestamp = Page.empty(); + Page resultModifiedTimestamp = Page.empty(); + if (queriesCreated) { + resultCreatedTimestamp = this.localPidStorage + .findDistinctPidsByCreatedBetween(createdAfter, createdBefore, pageable); + } + if (queriesModified) { + resultModifiedTimestamp = this.localPidStorage + .findDistinctPidsByModifiedBetween(modifiedAfter, modifiedBefore, pageable); + } + if (queriesCreated && queriesModified) { + final Page tmp = resultModifiedTimestamp; + final List intersection = resultCreatedTimestamp.filter((x) -> tmp.getContent().contains(x)).toList(); + return new PageImpl<>(intersection); + } else if (queriesCreated) { + return resultCreatedTimestamp; + } else if (queriesModified) { + return resultModifiedTimestamp; + } + return new PageImpl<>(this.localPidStorage.findAll()); + } + + @Override + public ResponseEntity> findAll( + Instant createdAfter, + Instant createdBefore, + Instant modifiedAfter, + Instant modifiedBefore, + Pageable pageable, + WebRequest request, + HttpServletResponse response, + UriComponentsBuilder uriBuilder) { + Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); + response.addHeader( + HeaderConstants.CONTENT_RANGE, + ControllerUtils.getContentRangeHeader( + page.getNumber(), + page.getSize(), + page.getTotalElements())); + return ResponseEntity.ok().body(page.getContent()); + } + + @Override + public ResponseEntity> findAllForTabular( + Instant createdAfter, + Instant createdBefore, + Instant modifiedAfter, + Instant modifiedBefore, + Pageable pageable, + WebRequest request, + HttpServletResponse response, + UriComponentsBuilder uriBuilder) { + Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); + response.addHeader( + HeaderConstants.CONTENT_RANGE, + ControllerUtils.getContentRangeHeader( + page.getNumber(), + page.getSize(), + page.getTotalElements())); + TabulatorPaginationFormat tabPage = new TabulatorPaginationFormat<>(page); + return ResponseEntity.ok().body(tabPage); + } + + private String quotedEtag(PIDRecord pidRecord) { + return String.format("\"%s\"", pidRecord.getEtag()); + } + +} diff --git a/src/test/java/edu/kit/datamanager/pit/configuration/HandleProtocolSetupTest.java b/src/test/java/edu/kit/datamanager/pit/configuration/HandleProtocolSetupTest.java index 11bd3630..49c3fa5e 100644 --- a/src/test/java/edu/kit/datamanager/pit/configuration/HandleProtocolSetupTest.java +++ b/src/test/java/edu/kit/datamanager/pit/configuration/HandleProtocolSetupTest.java @@ -13,7 +13,7 @@ import edu.kit.datamanager.pit.SpringTestHelper; import edu.kit.datamanager.pit.configuration.ApplicationProperties.IdentifierSystemImpl; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; @SpringBootTest() diff --git a/src/test/java/edu/kit/datamanager/pit/configuration/InMemorySetupTest.java b/src/test/java/edu/kit/datamanager/pit/configuration/InMemorySetupTest.java index 0092af73..8cc006ee 100644 --- a/src/test/java/edu/kit/datamanager/pit/configuration/InMemorySetupTest.java +++ b/src/test/java/edu/kit/datamanager/pit/configuration/InMemorySetupTest.java @@ -12,7 +12,7 @@ import edu.kit.datamanager.pit.SpringTestHelper; import edu.kit.datamanager.pit.configuration.ApplicationProperties.IdentifierSystemImpl; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; diff --git a/src/test/java/edu/kit/datamanager/pit/domain/TypeDefinitionTest.java b/src/test/java/edu/kit/datamanager/pit/domain/TypeDefinitionTest.java deleted file mode 100644 index a7db33f2..00000000 --- a/src/test/java/edu/kit/datamanager/pit/domain/TypeDefinitionTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package edu.kit.datamanager.pit.domain; - -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -@Disabled("Does not work yet due to the complexity of the TypeDefinition implementation. See TODO below.") -class TypeDefinitionTest { - - @Test - // TODO We should change the domain model so this or similar tests will run. - // But before we change the domain model to do so, we need a lot of tests to make sure things work as before after the changes. - // Currently deserialization is done in `TypeRegistry.constructTypeDefinition` in a very complicated way. - void deserialization() throws JsonMappingException, JsonProcessingException { - String type = "{\n" - + " \"identifier\": \"21.T11148/1c699a5d1b4ad3ba4956\",\n" - + " \"name\": \"digitalObjectType\",\n" - + " \"description\": \"Handle points to type definition in DTR for this type of object. Distinguishing metadata from data objects is a client decision within a particular usage context, which may to some extent rely on the digitalObjectType value provided. (context : KernelInformation)\\n\",\n" - + " \"standards\": [{\n" - + " \"natureOfApplicability\": \"depends\",\n" - + " \"name\": \"21.T11148/3626040cadcac1571685\",\n" - + " \"issuer\": \"DTR\"\n" - + " }],\n" - + " \"provenance\": {\n" - + " \"contributors\": [{\n" - + " \"identifiedUsing\": \"Text\",\n" - + " \"name\": \"Ulrich Schwardmann\",\n" - + " \"details\": \"GWDG\"\n" - + " }],\n" - + " \"creationDate\": \"2019-04-01T11:01:52.469Z\",\n" - + " \"lastModificationDate\": \"2019-11-14T12:28:19.011Z\"\n" - + " },\n" - + " \"representationsAndSemantics\": [{\n" - + " \"expression\": \"\",\n" - + " \"value\": \"\",\n" - + " \"subSchemaRelation\": \"denyAdditionalProperties\",\n" - + " \"allowAbbreviatedForm\": \"Yes\"\n" - + " }],\n" - + " \"properties\": [{\n" - + " \"name\": \"digitalObjectType\",\n" - + " \"identifier\": \"21.T11148/3626040cadcac1571685\",\n" - + " \"representationsAndSemantics\": [{\n" - + " \"expression\": \"\",\n" - + " \"value\": \"\",\n" - + " \"obligation\": \"Mandatory\",\n" - + " \"repeatable\": \"No\",\n" - + " \"allowOmitSubsidiaries\": \"Yes\"\n" - + " }]\n" - + " }],\n" - + " \"validationSchema\": \"{\\\"definitions\\\": {\\\"21.T11148_3626040cadcac1571685\\\": {\\\"pattern\\\": \\\"^([0-9,A-Z,a-z])+(\\\\\\\\.([0-9,A-Z,a-z])+)*\\\\\\\\/([!-~])+$\\\", \\\"type\\\": \\\"string\\\", \\\"description\\\": \\\"Handle-Identifier-ASCII@21.T11148/3626040cadcac1571685\\\"}}, \\\"$schema\\\": \\\"http://json-schema.org/draft-04/schema#\\\", \\\"description\\\": \\\"digitalObjectType@21.T11148/1c699a5d1b4ad3ba4956\\\", \\\"$ref\\\": \\\"#/definitions/21.T11148_3626040cadcac1571685\\\"}\"\n" - + "}"; - - ObjectMapper mapper = new ObjectMapper(); - TypeDefinition def = mapper.readValue(type, TypeDefinition.class); - System.out.println("DEF " + def.getExpression()); - assertNotNull(def); - } -} diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java index db1744f4..e3d30253 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java @@ -15,8 +15,7 @@ import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.configuration.HandleProtocolProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import net.handle.hdllib.HandleException; @@ -52,7 +51,7 @@ private static Stream implProvider() throws HandleException, IOExcept ); IIdentifierSystem inMemory = new InMemoryIdentifierSystem(); - String inMemoryPid = inMemory.registerPID(rec); + String inMemoryPid = inMemory.registerPid(rec); // TODO initiate REST impl @@ -64,20 +63,20 @@ private static Stream implProvider() throws HandleException, IOExcept @ParameterizedTest @MethodSource("implProvider") - public void isIdentifierRegisteredTrue(IIdentifierSystem impl, String pid) throws IOException { - assertTrue(impl.isIdentifierRegistered(pid)); + public void isPidRegisteredTrue(IIdentifierSystem impl, String pid) throws IOException { + assertTrue(impl.isPidRegistered(pid)); } @ParameterizedTest @MethodSource("implProvider") - public void isIdentifierRegisteredFalse(IIdentifierSystem impl, String pid, String pid_nonexist) throws IOException { - assertFalse(impl.isIdentifierRegistered(pid_nonexist)); + public void isPidRegisteredFalse(IIdentifierSystem impl, String pid, String pid_nonexist) throws IOException { + assertFalse(impl.isPidRegistered(pid_nonexist)); } @ParameterizedTest @MethodSource("implProvider") - public void queryAllPropertiesExample(IIdentifierSystem impl, String pid) throws IOException { - PIDRecord result = impl.queryAllProperties(pid); + public void queryPidExample(IIdentifierSystem impl, String pid) throws IOException { + PIDRecord result = impl.queryPid(pid); assertEquals(result.getPid(), pid); assertTrue(result.getPropertyIdentifiers().contains("10320/loc")); assertFalse(result.getPropertyIdentifiers().contains("HS_ADMIN")); @@ -85,40 +84,27 @@ public void queryAllPropertiesExample(IIdentifierSystem impl, String pid) throws @ParameterizedTest @MethodSource("implProvider") - public void queryAllPropertiesOfNonexistent(IIdentifierSystem impl, String _pid, String pid_nonexist) throws IOException { - PIDRecord result = impl.queryAllProperties(pid_nonexist); - assertNull(result); + public void queryPidOfNonexistent(IIdentifierSystem impl, String _pid, String pid_nonexist) throws IOException { + assertThrows(PidNotFoundException.class, () -> { + impl.queryPid(pid_nonexist); + }); } @ParameterizedTest @MethodSource("implProvider") public void querySingleProperty(IIdentifierSystem impl, String pid) throws IOException { - TypeDefinition type = new TypeDefinition(); - type.setIdentifier("10320/loc"); - type.setDescription("FakeType for testing. Actually describing the location in some handle specific format, and no registered type"); - String property = impl.queryProperty(pid, type); - assertTrue(property.contains("objects/21.T11148/076759916209e5d62bd5\" weight=\"1\" view=\"json\"")); - assertTrue(property.contains("#objects/21.T11148/076759916209e5d62bd5\" weight=\"0\" view=\"ui\"")); + PIDRecord record = impl.queryPid(pid); + String attributeKey = "10320/loc"; + assertTrue(record.getPropertyIdentifiers().contains(attributeKey)); + String value = record.getPropertyValue(attributeKey); + assertTrue(value.contains("objects/21.T11148/076759916209e5d62bd5\" weight=\"1\" view=\"json\"")); + assertTrue(value.contains("#objects/21.T11148/076759916209e5d62bd5\" weight=\"0\" view=\"ui\"")); } @ParameterizedTest @MethodSource("implProvider") public void queryNonexistentProperty(IIdentifierSystem impl, String pid) throws IOException { - TypeDefinition type = new TypeDefinition(); - type.setIdentifier("Nonexistent_Property"); - type.setDescription("FakeType for testing. Does not exist and query should fail somehow."); - String property = impl.queryProperty(pid, type); - assertNull(property); - } - - @ParameterizedTest - @MethodSource("implProvider") - public void queryPropertyOfNonexistent(IIdentifierSystem impl, String pid, String pid_nonexist) throws IOException { - TypeDefinition type = new TypeDefinition(); - type.setIdentifier("Nonexistent_Property"); - type.setDescription("FakeType for testing. Does not exist and query should fail somehow."); - assertThrows(PidNotFoundException.class, () -> { - impl.queryProperty(pid_nonexist, type); - }); + PIDRecord record = impl.queryPid(pid); + assertFalse(record.getPropertyIdentifiers().contains("Nonexistent_Attribute")); } } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemWriteTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemWriteTest.java index 86fd2c1c..f315595a 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemWriteTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemWriteTest.java @@ -62,7 +62,7 @@ void setup() throws InterruptedException, IOException { String value = pidGenerator.generate().getWithPrefix(PID_PREFIX); r.addEntry(attribute, "test", value); - localPidSystem.registerPID(r); + localPidSystem.registerPid(r); } @Test @@ -76,6 +76,6 @@ void testExtensiveRecordWithLocalPidSystem() throws IOException { assertEquals(numAttributes, r.getPropertyIdentifiers().size()); assertEquals(numValues, r.getPropertyValues(r.getPropertyIdentifiers().iterator().next()).length); - this.localPidSystem.registerPID(r); + this.localPidSystem.registerPid(r); } } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystemTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystemTest.java index d2c24ba1..d6ae1eff 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystemTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystemTest.java @@ -5,73 +5,36 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; class InMemoryIdentifierSystemTest { private InMemoryIdentifierSystem sys; - private TypeDefinition profile; - private TypeDefinition t1; - private TypeDefinition t2; - private TypeDefinition t3; @BeforeEach void setup() { this.sys = new InMemoryIdentifierSystem(); - this.t1 = new TypeDefinition(); - this.t1.setIdentifier("attribute1"); - this.t2 = new TypeDefinition(); - this.t2.setIdentifier("attribute2"); - this.t3 = new TypeDefinition(); - this.t3.setIdentifier("attribute3"); - - this.profile = new TypeDefinition(); - this.profile.setSubTypes(Map.of( - this.t1.getIdentifier(), this.t1, - this.t2.getIdentifier(), this.t2, - this.t3.getIdentifier(), this.t3 - )); - } - - @Test - void testQueryByType() throws IOException { - - PIDRecord p = new PIDRecord().withPID("test/pid"); - - // an empty registered record will return nothing - sys.registerPID(p); - PIDRecord queried = sys.queryByType(p.getPid(), profile); - assertTrue(queried.getPropertyIdentifiers().isEmpty()); - - // a record with matching types will return only those - p.addEntry(t1.getIdentifier(), "noName", "value"); - p.addEntry("something else", "noName", "noValue"); - sys.updatePID(p); - queried = sys.queryByType(p.getPid(), profile); - assertEquals(1, queried.getPropertyIdentifiers().size()); } @Test void testDeletePid() throws IOException { PIDRecord p = new PIDRecord().withPID("test/pid"); - sys.registerPID(p); + sys.registerPid(p); String pid = p.getPid(); assertThrows( UnsupportedOperationException.class, - () -> sys.deletePID(pid) + () -> sys.deletePid(pid) ); // actually, this is the case for any PID: assertThrows( UnsupportedOperationException.class, - () -> sys.deletePID("any PID") + () -> sys.deletePid("any PID") ); } @@ -80,11 +43,11 @@ void testResolveAll() throws InvalidConfigException, IOException { assertEquals(0, sys.resolveAllPidsOfPrefix().size()); PIDRecord p1 = new PIDRecord().withPID("p1"); - sys.registerPID(p1); + sys.registerPid(p1); assertEquals(1, sys.resolveAllPidsOfPrefix().size()); PIDRecord p2 = new PIDRecord().withPID("p2"); - sys.registerPID(p2); + sys.registerPid(p2); assertEquals(2, sys.resolveAllPidsOfPrefix().size()); } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapterTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiffTest.java similarity index 89% rename from src/test/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapterTest.java rename to src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiffTest.java index 04593b0e..fe8dcf39 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapterTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiffTest.java @@ -1,4 +1,4 @@ -package edu.kit.datamanager.pit.pidsystem.impl; +package edu.kit.datamanager.pit.pidsystem.impl.handle; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,10 +7,9 @@ import org.junit.jupiter.api.Test; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter.HandleDiff; import net.handle.hdllib.HandleValue; -class HandleProtocolAdapterTest { +class HandleDiffTest { @Test void testDiffOldRecordEmpty() { Map oldRecord = new HashMap<>(); @@ -70,11 +69,11 @@ void testDiffOneOfEachChange() { assertEquals(1, diff.added().length); } - private void addSomeHandleValue(Map record, int index) { + private static void addSomeHandleValue(Map record, int index) { record.put(index, getHandleValue(index)); } - private HandleValue getHandleValue(int index) { + private static HandleValue getHandleValue(int index) { return new HandleValue(index, "", ""); } } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndexTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndexTest.java new file mode 100644 index 00000000..5151e071 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndexTest.java @@ -0,0 +1,39 @@ +package edu.kit.datamanager.pit.pidsystem.impl.handle; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class HandleIndexTest { + + @Test + void isSkippingDefaultAdminIndex() { + HandleIndex handleIndex = new HandleIndex(); + for (int value = 1; value <= 200; value++) { + if (value >= handleIndex.getHsAdminIndex()) { + assertEquals(value + 1, handleIndex.nextIndex()); + } else { + assertEquals(value, handleIndex.nextIndex()); + } + } + } + + @Test + void isSkippingList() { + List skipping = List.of(3, 10, 42, 1337); + HandleIndex handleIndex = new HandleIndex().skipping(skipping); + + int lastValue = 0; + for (int _i = 1; _i <= 200; _i++) { + int value = handleIndex.nextIndex(); + assertTrue(value != handleIndex.getHsAdminIndex()); + for (int skip : skipping) { + assertNotEquals(value, skip); + } + assertTrue(value > lastValue); + lastValue = value; + } + } +} \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java index 91d92f4b..23978f7e 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java @@ -9,7 +9,6 @@ import java.lang.reflect.Method; import java.util.Arrays; import java.util.HashSet; -import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; @@ -23,7 +22,6 @@ import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystemQueryTest; /** @@ -47,11 +45,6 @@ class LocalPidSystemTest { @Autowired DataSourceProperties dataSourceProperties; - - private TypeDefinition profile; - private TypeDefinition t1; - private TypeDefinition t2; - private TypeDefinition t3; @BeforeEach void setup() throws InterruptedException, IOException { @@ -62,20 +55,6 @@ void setup() throws InterruptedException, IOException { assertNotNull(localPidSystem.getDatabase()); // ensure DB is empty localPidSystem.getDatabase().deleteAll(); - // prepare types and profiles - this.t1 = new TypeDefinition(); - this.t1.setIdentifier("attribute1"); - this.t2 = new TypeDefinition(); - this.t2.setIdentifier("attribute2"); - this.t3 = new TypeDefinition(); - this.t3.setIdentifier("attribute3"); - - this.profile = new TypeDefinition(); - this.profile.setSubTypes(Map.of( - this.t1.getIdentifier(), this.t1, - this.t2.getIdentifier(), this.t2, - this.t3.getIdentifier(), this.t3 - )); } @Test @@ -91,23 +70,23 @@ void testAllSystemTests() throws Exception { + "#objects/21.T11148/076759916209e5d62bd5\" weight=\"0\" view=\"ui\"" ); //rec.addEntry("10320/loc", "", "value"); - String pid = localPidSystem.registerPID(rec); + String pid = localPidSystem.registerPid(rec); assertEquals(rec.getPid(), pid); - PIDRecord newRec = localPidSystem.queryAllProperties(pid); + PIDRecord newRec = localPidSystem.queryPid(pid); assertEquals(rec, newRec); Set publicMethods = new HashSet<>(Arrays.asList(IIdentifierSystemQueryTest.class.getMethods())); Set allDirectMethods = new HashSet<>(Arrays.asList(IIdentifierSystemQueryTest.class.getDeclaredMethods())); publicMethods.retainAll(allDirectMethods); - assertEquals(7, publicMethods.size()); + assertEquals(6, publicMethods.size()); for (Method test : publicMethods) { int numParams = test.getParameterCount(); if (numParams == 2) { try { test.invoke(systemTests, localPidSystem, rec.getPid()); } catch (Exception e) { - System.err.println(String.format("Test: %s", test)); - System.err.println(String.format("Exception: %s", e)); + System.err.printf("Test: %s%n", test); + System.err.printf("Exception: %s%n", e); throw e; } } else if (numParams == 3) { @@ -120,38 +99,20 @@ void testAllSystemTests() throws Exception { } } - @Test - void testQueryByType() throws IOException { - - PIDRecord p = new PIDRecord().withPID("test/pid"); - - // an empty registered record will return nothing - this.localPidSystem.registerPID(p); - PIDRecord queried = this.localPidSystem.queryByType(p.getPid(), profile); - assertTrue(queried.getPropertyIdentifiers().isEmpty()); - - // a record with matching types will return only those - p.addEntry(t1.getIdentifier(), "noName", "value"); - p.addEntry("something else", "noName", "noValue"); - this.localPidSystem.updatePID(p); - queried = this.localPidSystem.queryByType(p.getPid(), profile); - assertEquals(1, queried.getPropertyIdentifiers().size()); - } - @Test void testDeletePid() throws IOException { PIDRecord p = new PIDRecord().withPID("test/pid"); - this.localPidSystem.registerPID(p); + this.localPidSystem.registerPid(p); String pid = p.getPid(); assertThrows( UnsupportedOperationException.class, - () -> this.localPidSystem.deletePID(pid) + () -> this.localPidSystem.deletePid(pid) ); // actually, this is the case for any PID: assertThrows( UnsupportedOperationException.class, - () -> this.localPidSystem.deletePID("any PID") + () -> this.localPidSystem.deletePid("any PID") ); } @@ -160,11 +121,11 @@ void testResolveAll() throws InvalidConfigException, IOException { assertEquals(0, this.localPidSystem.resolveAllPidsOfPrefix().size()); PIDRecord p1 = new PIDRecord().withPID("p1"); - this.localPidSystem.registerPID(p1); + this.localPidSystem.registerPid(p1); assertEquals(1, this.localPidSystem.resolveAllPidsOfPrefix().size()); PIDRecord p2 = new PIDRecord().withPID("p2"); - this.localPidSystem.registerPID(p2); + this.localPidSystem.registerPid(p2); assertEquals(2, this.localPidSystem.resolveAllPidsOfPrefix().size()); } } diff --git a/src/test/java/edu/kit/datamanager/pit/resolver/ResolverTest.java b/src/test/java/edu/kit/datamanager/pit/resolver/ResolverTest.java new file mode 100644 index 00000000..db26ecfa --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/resolver/ResolverTest.java @@ -0,0 +1,55 @@ +package edu.kit.datamanager.pit.resolver; + +import edu.kit.datamanager.pit.common.PidNotFoundException; +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.pitservice.ITypingService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@TestPropertySource("/test/application-test.properties") +@ActiveProfiles("test") +class ResolverTest { + + @Autowired + ITypingService identifierSystem; + + Resolver resolver; + + @BeforeEach + void setUp() { + resolver = new Resolver(this.identifierSystem); + } + + @Test + void resolveWithoutPrefix() { + assertThrows(PidNotFoundException.class, () -> resolver.resolve("test")); + } + + @Test + void resolveWithNonexistentPrefix() { + assertThrows(PidNotFoundException.class, () -> resolver.resolve("nonexistentprefix/test")); + } + + @Test + void resolveHandleReadOnly() { + PIDRecord result = resolver.resolve("10.5281/zenodo.8014937"); + assertNotNull(result); + } + + @Test + void resolveInMemory() { + PIDRecord record = new PIDRecord().withPID("suffix"); + record.addEntry("key", "value"); + String pid = this.identifierSystem.registerPid(record); + PIDRecord result = resolver.resolve(pid); + assertNotNull(result); + assertEquals("value", result.getEntries().get("key").getFirst().getValue()); + } +} \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/AttributeInfoTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/AttributeInfoTest.java new file mode 100644 index 00000000..ad9fedb9 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/AttributeInfoTest.java @@ -0,0 +1,85 @@ +package edu.kit.datamanager.pit.typeregistry; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AttributeInfoTest { + @Test + void valueToJsonNode_givenInteger_returnsNumberNode() { + var numberNode = AttributeInfo.valueToJsonNode("42"); + assertTrue(numberNode.isNumber()); + assertEquals(42, numberNode.numberValue()); + } + + @Test + void valueToJsonNode_givenString_returnsTextNode() { + var textNode = AttributeInfo.valueToJsonNode("Hello, World!"); + assertTrue(textNode.isTextual()); + assertEquals("Hello, World!", textNode.textValue()); + } + + @Test + void valueToJsonNode_givenBoolean_returnsBooleanNode() { + var booleanNode = AttributeInfo.valueToJsonNode("true"); + assertTrue(booleanNode.isBoolean()); + assertTrue(booleanNode.booleanValue()); + + booleanNode = AttributeInfo.valueToJsonNode("false"); + assertTrue(booleanNode.isBoolean()); + assertFalse(booleanNode.booleanValue()); + } + + @Test + void valueToJsonNode_givenNull_returnsNullNode() { + var nullNode = AttributeInfo.valueToJsonNode("null"); + assertTrue(nullNode.isNull()); + } + + @Test + void valueToJsonNode_givenJsonString_returnsJsonObject() { + var jsonString = "{\"key\": \"value\"}"; + var jsonNode = AttributeInfo.valueToJsonNode(jsonString); + assertTrue(jsonNode.isObject()); + assertEquals("value", jsonNode.get("key").textValue()); + } + + @Test + void valueToJsonNode_givenDecimalNumber_returnsNumberNode() { + var numberNode = AttributeInfo.valueToJsonNode("42.5"); + assertTrue(numberNode.isNumber()); + assertEquals(42.5, numberNode.doubleValue()); + } + + @Test + void valueToJsonNode_givenJsonArray_returnsArrayNode() { + var jsonArray = "[1, \"test\", true]"; + var arrayNode = AttributeInfo.valueToJsonNode(jsonArray); + assertTrue(arrayNode.isArray()); + assertEquals(3, arrayNode.size()); + assertTrue(arrayNode.get(0).isNumber()); + assertTrue(arrayNode.get(1).isTextual()); + assertTrue(arrayNode.get(2).isBoolean()); + } + + @Test + void valueToJsonNode_givenEmptyString_returnsTextNode() { + var node = AttributeInfo.valueToJsonNode(""); + assertTrue(node.isTextual()); + assertEquals("", node.textValue()); + } + + @Test + void valueToJsonNode_givenWhitespaceOnly_returnsTextNode() { + var node = AttributeInfo.valueToJsonNode(" "); + assertTrue(node.isTextual()); + assertEquals(" ", node.textValue()); + } + + @Test + void valueToJsonNode_givenLargeNumber_returnsNumberNode() { + var numberNode = AttributeInfo.valueToJsonNode("9223372036854775807"); // Long.MAX_VALUE + assertTrue(numberNode.isNumber()); + assertEquals(9223372036854775807L, numberNode.longValue()); + } +} \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java new file mode 100644 index 00000000..a69af98e --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java @@ -0,0 +1,50 @@ + package edu.kit.datamanager.pit.typeregistry.impl; + +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; + +import static org.junit.jupiter.api.Assertions.*; + +class TypeApiTest { + + // PID of checksum type in dtr-test. Currently (Dec 2024), the schema that type-api generated is malformed. + public static final String PID_COMPLEX_TYPE_CHECKSUM_DTRTEST = "21.T11148/82e2503c49209e987740"; + private final TypeApi dtr; + + TypeApiTest() throws MalformedURLException, URISyntaxException { + ApplicationProperties props = new ApplicationProperties(); + // set cache properties + props.setCacheExpireAfterWriteLifetime(10); + props.setCacheMaxEntries(1000); + // set type registry + props.setTypeRegistryUri(new URI("https://typeapi.lab.pidconsortium.net").toURL()); + props.setHandleBaseUri(new URI("https://hdl.handle.net").toURL()); + this.dtr = new TypeApi(props, new SchemaSetGenerator(props)); + } + + @Test + void queryAttributeInfoOfSimpleType() { + String attributePid = "21.T11148/b8457812905b83046284"; + AttributeInfo info = dtr.queryAttributeInfo(attributePid).join(); + assertEquals(attributePid, info.pid()); + assertFalse(info.jsonSchema().isEmpty()); + assertEquals(2, info.jsonSchema().size()); + assertTrue(info.name().contains("Location")); + assertEquals("PID-InfoType", info.typeName()); + } + + @Test + void queryAttributeInfoOfComplexType() { + AttributeInfo info = dtr.queryAttributeInfo(PID_COMPLEX_TYPE_CHECKSUM_DTRTEST).join(); + assertEquals(PID_COMPLEX_TYPE_CHECKSUM_DTRTEST, info.pid()); + assertFalse(info.jsonSchema().isEmpty()); + assertTrue(info.name().contains("checksum")); + assertEquals("PID-InfoType", info.typeName()); + } +} \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java deleted file mode 100644 index cc056d0e..00000000 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package edu.kit.datamanager.pit.typeregistry.impl; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.net.URISyntaxException; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.TestPropertySource; - -// JUnit5 + Spring -@SpringBootTest -// Set the in-memory implementation -@TestPropertySource(locations = "/test/application-test.properties", properties = "pit.pidsystem.implementation = LOCAL") -@ActiveProfiles("test") -class TypeRegistryTest { - - @Autowired - TypeRegistry typeRegistry; - - final String profileIdentifier = "21.T11148/b9b76f887845e32d29f7"; - - /** - * See if it does not only cache the sub-types but also the profile itself. - * - * @throws URISyntaxException - * @throws IOException - */ - @Test - void isCachingProfiles() throws IOException, URISyntaxException { - assertEquals( - null, - typeRegistry.typeCache.getIfPresent(profileIdentifier)); - assertEquals(0, typeRegistry.typeCache.size()); - - typeRegistry.queryTypeDefinition(profileIdentifier); - assertNotEquals( - null, - typeRegistry.typeCache.getIfPresent(profileIdentifier)); - // A profile definition contains type definitions. - // The cache therefore should have more than one identifiers in cache. - assertTrue(typeRegistry.typeCache.size() > 1); - } -} diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java new file mode 100644 index 00000000..2ed50b74 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java @@ -0,0 +1,56 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.net.URI; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class SchemaSetGeneratorTest { + + static ApplicationProperties properties; + static SchemaSetGenerator generator; + + @BeforeAll + static void setup() throws Exception { + properties = new ApplicationProperties(); + properties.setCacheExpireAfterWriteLifetime(10); + properties.setCacheMaxEntries(1000); + properties.setTypeRegistryUri(new URI("https://typeapi.lab.pidconsortium.net").toURL()); + properties.setHandleBaseUri(new URI("https://hdl.handle.net").toURL()); + generator = new SchemaSetGenerator(properties); + } + + private static Stream typeWithExamplesAndCounterexamples() { + return Stream.of( // typePid, example, counterexample + // checksum + Arguments.of( + "21.T11148/92e200311a56800b3e47", + "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }", + "\"blabla c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\""), + // checksum + Arguments.of( + "21.T11148/92e200311a56800b3e47", + "\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"", + "\"not a checksum\""), + // URI with schema making use of "format" to specify an uri + Arguments.of("21.T11969/cb371c93c5aa0e62198e", "\"https://example.com\"", "This is not a URI") + ); + } + + @ParameterizedTest + @MethodSource("typeWithExamplesAndCounterexamples") + void testExampleAndCounterexample(String typePid, String example, String counterexample) { + Set schemaInfos = generator.generateFor(typePid).join(); + AttributeInfo attributeInfo = new AttributeInfo(typePid, "name", "typeName", schemaInfos); + assertTrue(attributeInfo.validate(example)); + assertFalse(attributeInfo.validate(counterexample)); + } +} \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java new file mode 100644 index 00000000..ea729b98 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java @@ -0,0 +1,1001 @@ + +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.kit.datamanager.pit.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.pidlog.KnownPidsDao; +import edu.kit.datamanager.pit.pitservice.ITypingService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.web.context.WebApplicationContext; +import org.hamcrest.Matchers; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +@Slf4j +@AutoConfigureMockMvc +@SpringBootTest +@TestPropertySource("/test/application-test.properties") +@ActiveProfiles("test") +class ConnectedPIDsTest { + private static final int RECORD_COUNT = 16; + private static final int LARGE_RECORD_COUNT = 200; + + // Valid DTR keys for testing connections + private static final String[] VALID_CONNECTION_KEYS = { + "21.T11148/432132bdbd946b2baf2b", + "21.T11148/ab53242825e85a0a7f76", + "21.T11148/2a1cad55473b20407c78" + }; + + @Autowired + private WebApplicationContext webApplicationContext; + private MockMvc mockMvc; + private ObjectMapper mapper; + @Autowired + private KnownPidsDao knownPidsDao; + @Autowired + private ITypingService typingService; + + @BeforeEach + void setup() { + this.mockMvc = webAppContextSetup(webApplicationContext).build(); + this.mapper = new ObjectMapper(); + knownPidsDao.deleteAll(); + } + + @Test + void checkTestSetup() { + assertNotNull(mockMvc, "MockMvc should be initialized"); + assertNotNull(mapper, "Object mapper should be initialized"); + assertNotNull(knownPidsDao, "KnownPidsDao should be initialized"); + assertEquals(0, knownPidsDao.count(), "Database should be empty at test start"); + } + + @Test + @DisplayName("Test comprehensive PIDBuilder functionality") + void testPIDBuilderAllMethods() { + Long testSeed = 12345L; + + // Test default constructor + PIDBuilder defaultBuilder = new PIDBuilder(); + assertNotNull(defaultBuilder, "Default PIDBuilder constructor should create non-null instance"); + assertNotNull(defaultBuilder.build(), "Default PIDBuilder should build non-null PID"); + + // Test constructor with seed + PIDBuilder seededBuilder = new PIDBuilder(testSeed); + assertNotNull(seededBuilder, "Seeded PIDBuilder constructor should create non-null instance"); + assertEquals(testSeed, seededBuilder.seed, "Seeded PIDBuilder should have the correct seed value"); + + // Test withSeed method + PIDBuilder seedModified = new PIDBuilder().withSeed(testSeed); + assertEquals(testSeed, seedModified.seed, "withSeed method should set the correct seed value"); + + // Test all prefix methods + PIDBuilder validPrefixBuilder = new PIDBuilder(testSeed).validPrefix(); + String validPid = validPrefixBuilder.build(); + assertTrue(validPid.startsWith("sandboxed/"), "validPrefix should create PID starting with 'sandboxed/'"); + + PIDBuilder unauthorizedPrefixBuilder = new PIDBuilder(testSeed).unauthorizedPrefix(); + String unauthorizedPid = unauthorizedPrefixBuilder.build(); + assertTrue(unauthorizedPid.startsWith("0.NA/"), "unauthorizedPrefix should create PID starting with '0.NA/'"); + + PIDBuilder emptyPrefixBuilder = new PIDBuilder(testSeed).emptyPrefix(); + String emptyPrefixPid = emptyPrefixBuilder.build(); + assertTrue(emptyPrefixPid.startsWith("/"), "emptyPrefix should create PID starting with '/'"); + + PIDBuilder invalidPrefixBuilder = new PIDBuilder(testSeed).invalidCharactersPrefix(); + String invalidPrefixPid = invalidPrefixBuilder.build(); + assertNotNull(invalidPrefixPid, "invalidCharactersPrefix should create non-null PID"); + + // Test withPrefix method + PIDBuilder customPrefixBuilder = new PIDBuilder(testSeed).withPrefix("custom.prefix"); + String customPrefixPid = customPrefixBuilder.build(); + assertTrue(customPrefixPid.startsWith("custom.prefix/"), "withPrefix should create PID starting with the specified prefix"); + + // Test all suffix methods + PIDBuilder validSuffixBuilder = new PIDBuilder(testSeed).validPrefix().validSuffix(); + String validSuffixPid = validSuffixBuilder.build(); + assertNotNull(validSuffixPid, "validSuffix should create non-null PID"); + + PIDBuilder emptySuffixBuilder = new PIDBuilder(testSeed).validPrefix().emptySuffix(); + String emptySuffixPid = emptySuffixBuilder.build(); + assertTrue(emptySuffixPid.endsWith("/"), "emptySuffix should create PID ending with '/'"); + + PIDBuilder invalidSuffixBuilder = new PIDBuilder(testSeed).validPrefix().invalidCharactersSuffix(); + String invalidSuffixPid = invalidSuffixBuilder.build(); + assertNotNull(invalidSuffixPid, "invalidCharactersSuffix should create non-null PID"); + + // Test withSuffix method + PIDBuilder customSuffixBuilder = new PIDBuilder(testSeed).validPrefix().withSuffix("custom-suffix"); + String customSuffixPid = customSuffixBuilder.build(); + assertTrue(customSuffixPid.endsWith("custom-suffix"), "withSuffix should create PID ending with the specified suffix"); + + // Test clone method + PIDBuilder originalBuilder = new PIDBuilder(testSeed).validPrefix().validSuffix(); + PIDBuilder clonedBuilder = originalBuilder.clone(); + assertEquals(originalBuilder.build(), clonedBuilder.build(), "Cloned builder should produce the same PID"); + assertNotSame(originalBuilder, clonedBuilder, "Cloned builder should be a different instance"); + + // Test clone(PIDBuilder) method + PIDBuilder targetBuilder = new PIDBuilder(); + targetBuilder.copyFrom(originalBuilder); + assertEquals(originalBuilder.build(), targetBuilder.build(), "Target builder should produce the same PID after cloning"); + + // Test equals and hashCode + PIDBuilder builder1 = new PIDBuilder(testSeed).validPrefix().validSuffix(); + PIDBuilder builder2 = new PIDBuilder(testSeed).validPrefix().validSuffix(); + assertEquals(builder1, builder2, "Builders with same configuration should be equal"); + assertEquals(builder1.hashCode(), builder2.hashCode(), "Equal builders should have same hash code"); + + // Test toString + assertNotNull(originalBuilder.toString(), "toString should not return null"); + assertTrue(originalBuilder.toString().contains("PIDBuilder"), "toString should contain class name"); + } + + @Test + @DisplayName("Test comprehensive PIDRecordBuilder functionality") + void testPIDRecordBuilderAllMethods() { + Long testSeed = 67890L; + + // Test default constructor + PIDRecordBuilder defaultBuilder = new PIDRecordBuilder(); + assertNotNull(defaultBuilder, "Default PIDRecordBuilder constructor should create non-null instance"); + assertNotNull(defaultBuilder.build(), "Default PIDRecordBuilder should build non-null record"); + + // Test constructor with PIDBuilder + PIDBuilder pidBuilder = new PIDBuilder(testSeed).validPrefix().validSuffix(); + PIDRecordBuilder builderWithPid = new PIDRecordBuilder(pidBuilder); + assertNotNull(builderWithPid, "PIDRecordBuilder with PIDBuilder should create non-null instance"); + assertEquals(pidBuilder.build(), builderWithPid.build().getPid(), "PIDRecordBuilder should use PID from provided PIDBuilder"); + + // Test constructor with PIDBuilder and seed + PIDRecordBuilder builderWithSeed = new PIDRecordBuilder(pidBuilder, testSeed); + assertNotNull(builderWithSeed, "PIDRecordBuilder with PIDBuilder and seed should create non-null instance"); + assertEquals(testSeed, builderWithSeed.seed, "PIDRecordBuilder should store the provided seed"); + + // Test withSeed method + PIDRecordBuilder seedModified = new PIDRecordBuilder().withSeed(testSeed); + assertEquals(testSeed, seedModified.seed, "withSeed method should set the correct seed value"); + + // Test withPid method + String customPid = "test/custom-pid"; + PIDRecordBuilder pidModified = new PIDRecordBuilder().withPid(customPid); + assertEquals(customPid, pidModified.build().getPid(), "withPid method should set the custom PID value"); + + // Test the completeProfile method + PIDRecordBuilder profileBuilder = new PIDRecordBuilder().completeProfile(); + PIDRecord profileRecord = profileBuilder.build(); + assertNotNull(profileRecord, "completeProfile should produce a non-null record"); + assertFalse(profileRecord.getEntries().isEmpty(), "completeProfile should generate record entries"); + + // Test incompleteProfile method + PIDRecordBuilder incompleteBuilder = new PIDRecordBuilder().incompleteProfile(); + PIDRecord incompleteRecord = incompleteBuilder.build(); + assertNotNull(incompleteRecord, "incompleteProfile should produce a non-null record"); + + // Test invalidValues method with different parameters + PIDRecordBuilder invalidValuesBuilder1 = new PIDRecordBuilder().invalidValues(3); + PIDRecord invalidRecord1 = invalidValuesBuilder1.build(); + assertNotNull(invalidRecord1, "invalidValues(3) should produce a non-null record"); + + PIDRecordBuilder invalidValuesBuilder2 = new PIDRecordBuilder().invalidValues(2, "21.T11148/397d831aa3a9d18eb52c"); + PIDRecord invalidRecord2 = invalidValuesBuilder2.build(); + assertNotNull(invalidRecord2, "invalidValues with specific key should produce a non-null record"); + + PIDRecordBuilder invalidValuesBuilder3 = new PIDRecordBuilder().invalidValues(0); + PIDRecord invalidRecord3 = invalidValuesBuilder3.build(); + assertNotNull(invalidRecord3, "invalidValues(0) should produce a non-null record"); + + // Test invalidKeys method + PIDRecordBuilder invalidKeysBuilder = new PIDRecordBuilder().invalidKeys(3); + PIDRecord invalidKeysRecord = invalidKeysBuilder.build(); + assertNotNull(invalidKeysRecord, "invalidKeys should produce a non-null record"); + assertTrue(invalidKeysRecord.getEntries().size() >= 3, "invalidKeys(3) should generate at least 3 entries"); + + // Test emptyRecord method + PIDRecordBuilder emptyBuilder = new PIDRecordBuilder().completeProfile().emptyRecord(); + PIDRecord emptyRecord = emptyBuilder.build(); + assertNotNull(emptyRecord, "emptyRecord should produce a non-null record"); + assertEquals(0, emptyRecord.getEntries().size(), "emptyRecord should have no entries"); + + // Test nullRecord method + PIDRecordBuilder nullBuilder = new PIDRecordBuilder().nullRecord(); + assertThrows(Exception.class, nullBuilder::build, "nullRecord should throw exception when built"); + + // Test withPIDRecord method + PIDRecord existingRecord = new PIDRecord().withPID("test/existing"); + existingRecord.addEntry("test.key", "test.value"); + PIDRecordBuilder recordBuilder = new PIDRecordBuilder().withPIDRecord(existingRecord); + assertEquals(existingRecord.getPid(), recordBuilder.build().getPid(), "withPIDRecord should use PID from existing record"); + + // Test clone method + PIDRecordBuilder originalRecordBuilder = new PIDRecordBuilder().completeProfile(); + PIDRecordBuilder clonedRecordBuilder = originalRecordBuilder.clone(); + assertNotSame(originalRecordBuilder, clonedRecordBuilder, "Cloned builder should be a different instance"); + assertEquals(originalRecordBuilder.seed, clonedRecordBuilder.seed, "Cloned builder should have the same seed"); + + // Test equals and hashCode + PIDRecordBuilder builder1 = new PIDRecordBuilder(null, testSeed).completeProfile(); + PIDRecordBuilder builder2 = new PIDRecordBuilder(null, testSeed).completeProfile(); + // Note: equals might not be equal due to random elements, but we test the method exists + assertEquals(builder1, builder2, "Builders with same configuration should be equal"); + assertNotEquals(0, builder1.hashCode(), "hashCode should not return null"); + + // Test toString + assertNotNull(originalRecordBuilder.toString(), "toString should not return null"); + assertTrue(originalRecordBuilder.toString().contains("PIDRecordBuilder"), "toString should contain class name"); + } + + @Test + @DisplayName("Test PIDRecordBuilder connection functionality") + void testPIDRecordBuilderConnections() { + long testSeed = 111L; + + // Create multiple builders for connection testing + PIDRecordBuilder builder1 = new PIDRecordBuilder(null, testSeed).completeProfile(); + PIDRecordBuilder builder2 = new PIDRecordBuilder(null, testSeed + 1).completeProfile(); + PIDRecordBuilder builder3 = new PIDRecordBuilder(null, testSeed + 2).completeProfile(); + + // Test addConnection method + String connectionKey = "21.T11148/d0773859091aeb451528"; + builder1.addConnection(connectionKey, false, builder2, builder3); + + PIDRecord connectedRecord = builder1.build(); + assertTrue(connectedRecord.hasProperty(connectionKey), "Record should have the connection property after addConnection"); + + // Test addConnection with replacement + builder1.addConnection(connectionKey, true, builder2); + PIDRecord replacedRecord = builder1.build(); + assertTrue(replacedRecord.hasProperty(connectionKey), "Record should have connection property after replace"); + + // Test addConnection error case + assertThrows(IllegalArgumentException.class, () -> + builder1.addConnection(connectionKey, false), "addConnection should throw exception when no builders provided"); + + // Test connectRecordBuilders static method with default keys + List connectedBuilders = PIDRecordBuilder.connectRecordBuilders( + null, null, false, builder1, builder2, builder3); + + assertEquals(3, connectedBuilders.size(), "connectRecordBuilders should return the same number of builders"); + + // Verify connections were established + for (PIDRecordBuilder builder : connectedBuilders) { + PIDRecord record = builder.build(); + assertTrue(record.hasProperty("21.T11148/d0773859091aeb451528") || + record.hasProperty("21.T11148/4fe7cde52629b61e3b82"), "Connected records should have forward or backward connection property"); + } + + // Test connectRecordBuilders with custom keys + PIDRecordBuilder builder4 = new PIDRecordBuilder(null, testSeed + 3).completeProfile(); + PIDRecordBuilder builder5 = new PIDRecordBuilder(null, testSeed + 4).completeProfile(); + + List customConnectedBuilders = PIDRecordBuilder.connectRecordBuilders( + "custom.forward.key", "custom.backward.key", true, builder4, builder5); + + assertEquals(2, customConnectedBuilders.size(), "connectRecordBuilders with custom keys should return the correct number of builders"); + + // Test connectRecordBuilders error case + assertThrows(IllegalArgumentException.class, () -> + PIDRecordBuilder.connectRecordBuilders(null, null, false, builder1), "connectRecordBuilders should throw exception with single builder"); + } + + @Test + @DisplayName("Test valid connected records creation") + void checkValidConnectedRecords() throws Exception { + // Create connected records using all builder functionality + long baseSeed = 12345L; + + List records = new ArrayList<>(); + + // Use different PIDBuilder configurations + PIDBuilder[] pidBuilders = { + new PIDBuilder(baseSeed).validPrefix().validSuffix(), + new PIDBuilder(baseSeed + 1).validPrefix().validSuffix(), + new PIDBuilder(baseSeed + 2).validPrefix().validSuffix() + }; + + PIDRecordBuilder[] builders = new PIDRecordBuilder[pidBuilders.length]; + for (int i = 0; i < pidBuilders.length; i++) { + builders[i] = new PIDRecordBuilder(pidBuilders[i], baseSeed + i) + .completeProfile() + .withSeed(baseSeed + i); + } + + // Connect the builders + PIDRecordBuilder.connectRecordBuilders(null, null, false, builders); + + // Build the records + for (PIDRecordBuilder builder : builders) { + records.add(builder.build()); + } + + // Submit to API + String jsonContent = mapper.writeValueAsString(records); + + String prefix = this.typingService.getPrefix() + .orElseThrow(() -> new IOException("No prefix configured.")); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(records.size())) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").isMap()) + // The mapping values must contain a full PID, not only the suffix, + // to prevent client-side errors with partial PIDs. + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping") + .value(Matchers.hasValue(Matchers.startsWith(prefix))) + ); + + assertEquals(records.size(), knownPidsDao.count(), "Number of stored records should match the number of records submitted"); + } + + + @Test + @DisplayName("Test single valid record creation") + void testCreateSingleValidRecord() throws Exception { + // Use comprehensive PIDBuilder configuration + PIDBuilder pidBuilder = new PIDBuilder(999L) + .withSeed(999L) + .validPrefix() + .validSuffix(); + + PIDRecord record = new PIDRecordBuilder(pidBuilder) + .withSeed(999L) + .completeProfile() + .build(); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(1)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); + + assertEquals(1, knownPidsDao.count(), "Exactly one record should be stored in the database"); + } + + + @Test + @DisplayName("Test empty list") + void testCreateEmptyList() throws Exception { + List emptyRecords = new ArrayList<>(); + String jsonContent = mapper.writeValueAsString(emptyRecords); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + assertEquals(0, knownPidsDao.count(), "No records should be stored in database when submitting an empty list"); + } + + @Test + @DisplayName("Test dryrun functionality") + void testDryRun() throws Exception { + PIDRecord record = new PIDRecordBuilder() + .completeProfile() + .build(); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent) + .param("dryrun", "true")) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); + + assertEquals(0, knownPidsDao.count(), "No records should be stored in database when using dryrun mode"); + } + + @Test + @DisplayName("Test invalid JSON format") + void testInvalidJsonFormat() throws Exception { + String invalidJson = "{ invalid json }"; + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + @DisplayName("Test unsupported media type") + void testUnsupportedMediaType() throws Exception { + PIDRecord record = new PIDRecordBuilder().completeProfile().build(); + List records = List.of(record); + String content = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.TEXT_PLAIN) + .content(content)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isUnsupportedMediaType()); + } + + @Test + @DisplayName("Test records with circular references") + void testCircularReferences() throws Exception { + // Create builders with circular connections + PIDRecordBuilder builder1 = new PIDRecordBuilder(null, 100L).completeProfile(); + PIDRecordBuilder builder2 = new PIDRecordBuilder(null, 101L).completeProfile(); + + // Create circular reference + builder1.addConnection("21.T11148/d0773859091aeb451528", false, builder2); + builder2.addConnection("21.T11148/4fe7cde52629b61e3b82", false, builder1); + + List records = List.of(builder1.build(), builder2.build()); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(2)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); + + assertEquals(2, knownPidsDao.count(), "Both circularly connected records should be stored in the database"); + } + + @Test + @DisplayName("Test records with duplicate temporary PIDs") + void testDuplicateTemporaryPids() throws Exception { + // Create records with same PID using same seed + Long sameSeed = 555L; + PIDBuilder sameBuilder = new PIDBuilder(sameSeed).validPrefix().validSuffix(); + + PIDRecord record1 = new PIDRecordBuilder(sameBuilder.clone(), sameSeed).completeProfile().build(); + PIDRecord record2 = new PIDRecordBuilder(sameBuilder.clone(), sameSeed).completeProfile().build(); + + List records = List.of(record1, record2); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + @DisplayName("Test records with missing entries") + void testRecordsWithMissingEntries() throws Exception { + // Create an incomplete record + PIDRecord incompleteRecord = new PIDRecordBuilder() + .incompleteProfile() + .build(); + + List records = List.of(incompleteRecord); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + @DisplayName("Test large number of connected records") + void testLargeNumberOfConnectedRecords() throws Exception { + List builders = new ArrayList<>(); + + for (int i = 0; i < LARGE_RECORD_COUNT; i++) { + PIDBuilder pidBuilder = new PIDBuilder((long) i).validPrefix().validSuffix(); + builders.add(new PIDRecordBuilder(pidBuilder, (long) i).completeProfile()); + } + + // Connect all builders + PIDRecordBuilder.connectRecordBuilders(null, null, false, + builders.toArray(new PIDRecordBuilder[0])); + + List records = new ArrayList<>(); + for (PIDRecordBuilder builder : builders) { + records.add(builder.build()); + } + + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(LARGE_RECORD_COUNT)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); + + assertEquals(LARGE_RECORD_COUNT, knownPidsDao.count(), "All records from large batch should be stored in the database"); + } + + @Test + @DisplayName("Test records with external references") + void testRecordsWithExternalReferences() throws Exception { + PIDRecord record = new PIDRecordBuilder() + .completeProfile() + .build(); + + // Add external reference + record.addEntry("21.T11148/d0773859091aeb451528", "externalRef", "external/pid/reference"); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(1)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); + + assertEquals(1, knownPidsDao.count(), "Record with external reference should be stored in the database"); + } + + + @Test + @DisplayName("Test records with mixed connection types") + void testMixedConnectionTypes() throws Exception { + PIDRecordBuilder builder1 = new PIDRecordBuilder(null, 200L).completeProfile(); + PIDRecordBuilder builder2 = new PIDRecordBuilder(null, 201L).completeProfile(); + PIDRecordBuilder builder3 = new PIDRecordBuilder(null, 202L).completeProfile(); + + // Use different connection keys + builder1.addConnection("21.T11148/d0773859091aeb451528", false, builder2); + builder2.addConnection("21.T11148/4fe7cde52629b61e3b82", false, builder3); + builder3.addConnection(VALID_CONNECTION_KEYS[0], false, builder1); + + List records = List.of(builder1.build(), builder2.build(), builder3.build()); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(3)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); + + assertEquals(3, knownPidsDao.count(), "All three records with mixed connection types should be stored in the database"); + } + + @Test + @DisplayName("Test records with null PIDs") + void testRecordsWithNullPids() throws Exception { + PIDRecord record = new PIDRecordBuilder() + .completeProfile() + .withPid(null) + .build(); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(1)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); + } + + @Test + @DisplayName("Test PID mapping persistence") + void testPidMappingPersistence() throws Exception { + PIDRecord record = new PIDRecordBuilder() + .completeProfile() + .build(); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(1, knownPidsDao.count(), "One record should be stored in the database"); + + // Verify the PID was actually stored + String storedPid = knownPidsDao.findAll().getFirst().getPid(); + assertNotNull(storedPid, "Stored PID should not be null"); + assertFalse(storedPid.isEmpty(), "Stored PID should not be empty"); + } + + @Test + @DisplayName("Test PIDBuilder edge cases and combinations") + void testPIDBuilderEdgeCases() { + // Test various combinations of prefix and suffix methods + Long seed = 777L; + + // Test unauthorized prefix with valid suffix + PIDBuilder unauthorizedValid = new PIDBuilder(seed) + .unauthorizedPrefix() + .validSuffix(); + String unauthorizedValidPid = unauthorizedValid.build(); + assertTrue(unauthorizedValidPid.startsWith("0.NA/"), "Unauthorized prefix with valid suffix should start with '0.NA/'"); + + // Test empty prefix with empty suffix + PIDBuilder emptyEmpty = new PIDBuilder(seed) + .emptyPrefix() + .emptySuffix(); + String emptyEmptyPid = emptyEmpty.build(); + assertEquals("/", emptyEmptyPid, "Empty prefix with empty suffix should result in just '/'"); + + // Test invalid characters combinations + PIDBuilder invalidCombination = new PIDBuilder(seed) + .invalidCharactersPrefix() + .invalidCharactersSuffix(); + String invalidPid = invalidCombination.build(); + assertNotNull(invalidPid, "Invalid characters combination should still produce non-null PID"); + assertTrue(invalidPid.contains("/"), "Invalid characters combination should still contain the separator"); + + // Test custom prefix with custom suffix + PIDBuilder customBoth = new PIDBuilder(seed) + .withPrefix("test.prefix") + .withSuffix("test-suffix"); + String customPid = customBoth.build(); + assertEquals("test.prefix/test-suffix", customPid, "Custom prefix and suffix should be combined correctly"); + } + + @Test + @DisplayName("Test PIDRecordBuilder with various invalid configurations") + void testPIDRecordBuilderInvalidConfigurations() throws Exception { + // Test record with invalid keys + PIDRecord invalidKeysRecord = new PIDRecordBuilder() + .invalidKeys(5) + .build(); + + assertTrue(invalidKeysRecord.getEntries().size() >= 5, "invalidKeys(5) should generate at least 5 entries"); + + // Test record with invalid values for specific keys + PIDRecord invalidSpecificRecord = new PIDRecordBuilder() + .completeProfile() + .invalidValues(2, "21.T11148/397d831aa3a9d18eb52c", "21.T11148/8074aed799118ac263ad") + .build(); + + assertNotNull(invalidSpecificRecord, "Invalid values for specific keys should produce non-null record"); + + // Test empty record + PIDRecord emptyRecord = new PIDRecordBuilder() + .emptyRecord() + .build(); + + assertEquals(0, emptyRecord.getEntries().size(), "emptyRecord should have no entries"); + + // Submit invalid records to test API response + List invalidRecords = List.of(invalidKeysRecord); + String jsonContent = mapper.writeValueAsString(invalidRecords); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + @DisplayName("Test builder chaining and method combinations") + void testBuilderChainingCombinations() { + Long seed = 888L; + + // Test complex PIDBuilder chaining + PIDBuilder complexBuilder = new PIDBuilder() + .withSeed(seed) + .validPrefix() + .validSuffix() + .withPrefix("chained.prefix") + .withSuffix("chained-suffix"); + + String complexPid = complexBuilder.build(); + assertEquals("chained.prefix/chained-suffix", complexPid, "Chained builder methods should override previous settings"); + + // Test complex PIDRecordBuilder chaining + PIDRecordBuilder complexRecordBuilder = new PIDRecordBuilder() + .withSeed(seed) + .completeProfile() + .withPid("custom/pid") + .invalidValues(1, "21.T11148/397d831aa3a9d18eb52c") + .invalidKeys(1); + + PIDRecord complexRecord = complexRecordBuilder.build(); + assertEquals("custom/pid", complexRecord.getPid(), "Chained record builder should use the custom PID"); + assertFalse(complexRecord.getEntries().isEmpty(), "Chained record builder should generate entries"); + + // Test cloning and modification + PIDRecordBuilder cloned = complexRecordBuilder.clone(); + cloned.withPid("different/pid"); + + assertNotEquals(complexRecordBuilder.build().getPid(), cloned.build().getPid(), "Modifying a cloned builder should not affect the original"); + } + + @Test + @DisplayName("Test multiple record creation with using RECORD_COUNT constant") + void testMultipleRecordCreationWithRecordCount() throws Exception { + List records = new ArrayList<>(); + + // Create multiple records using RECORD_COUNT constant + for (int i = 0; i < RECORD_COUNT; i++) { + PIDBuilder pidBuilder = new PIDBuilder((long) i).validPrefix().validSuffix(); + PIDRecord record = new PIDRecordBuilder(pidBuilder, (long) i) + .completeProfile() + .build(); + records.add(record); + } + + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(RECORD_COUNT)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); + + assertEquals(RECORD_COUNT, knownPidsDao.count(), "All RECORD_COUNT records should be stored in the database"); + } + + @Test + @DisplayName("Test connected records with RECORD_COUNT") + void testConnectedRecordsWithRecordCount() throws Exception { + List builders = new ArrayList<>(); + + // Create RECORD_COUNT builders for connection testing + for (int i = 0; i < RECORD_COUNT; i++) { + PIDBuilder pidBuilder = new PIDBuilder((long) i).validPrefix().validSuffix(); + builders.add(new PIDRecordBuilder(pidBuilder, (long) i).completeProfile()); + } + + // Connect all builders + PIDRecordBuilder.connectRecordBuilders(null, null, false, + builders.toArray(new PIDRecordBuilder[0])); + + List records = new ArrayList<>(); + for (PIDRecordBuilder builder : builders) { + records.add(builder.build()); + } + + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(RECORD_COUNT)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); + + assertEquals(RECORD_COUNT, knownPidsDao.count(), "All RECORD_COUNT connected records should be stored in the database"); + } + + @Test + @DisplayName("Test partial failure and rollback scenario") + void testPartialFailureAndRollback() throws Exception { + List records = new ArrayList<>(); + + // Create some valid records + for (int i = 0; i < 3; i++) { + PIDRecord validRecord = new PIDRecordBuilder() + .completeProfile() + .build(); + records.add(validRecord); + } + + // Add records that should cause validation or creation failures + // Record with incomplete profile (missing required entries) + PIDRecord incompleteRecord = new PIDRecordBuilder() + .incompleteProfile() + .build(); + records.add(incompleteRecord); + + // Record with invalid keys + PIDRecord invalidKeysRecord = new PIDRecordBuilder() + .invalidKeys(3) + .build(); + records.add(invalidKeysRecord); + + // Record with invalid values for specific keys + PIDRecord invalidValuesRecord = new PIDRecordBuilder() + .completeProfile() + .invalidValues(2, "21.T11148/397d831aa3a9d18eb52c") + .build(); + records.add(invalidValuesRecord); + + String jsonContent = mapper.writeValueAsString(records); + + // This should result in a server error due to failed validation/ creation, + // and the rollback mechanism should be triggered + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + // Verify that no PIDs were persisted due to rollback + assertEquals(0, knownPidsDao.count(), "Rollback should prevent any records from being stored due to partial failures"); + } + + @Test + @DisplayName("Test batch with mixed valid and invalid records for rollback coverage") + void testBatchWithMixedRecordsForRollbackCoverage() throws Exception { + List records = new ArrayList<>(); + + // Create valid records that would initially succeed + for (int i = 0; i < RECORD_COUNT / 2; i++) { + PIDRecord record = new PIDRecordBuilder() + .withSeed((long) i) + .completeProfile() + .build(); + records.add(record); + } + + // Add records with various failure scenarios to trigger rollback + + // Empty record (no entries) + PIDRecord emptyRecord = new PIDRecordBuilder() + .emptyRecord() + .build(); + records.add(emptyRecord); + + // Record with null PID and incomplete profile + PIDRecord nullPidRecord = new PIDRecordBuilder() + .withPid(null) + .incompleteProfile() + .build(); + records.add(nullPidRecord); + + // Record with completely invalid data + PIDRecord invalidRecord = new PIDRecordBuilder() + .invalidKeys(5) + .invalidValues(3) + .build(); + records.add(invalidRecord); + + // Incomplete record with missing required entries + PIDRecord incompleteRecord = new PIDRecordBuilder() + .incompleteProfile() + .build(); + records.add(incompleteRecord); + + String jsonContent = mapper.writeValueAsString(records); + + // Expect server error due to validation failures + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + // Verify rollback: no records should be persisted + assertEquals(0, knownPidsDao.count(), "Rollback should prevent any records from being stored due to mixed valid and invalid records"); + } + + @Test + @DisplayName("Test record creation failure scenarios with duplicate PIDs in batch") + void testRecordCreationFailureWithDuplicatePids() throws Exception { + List records = new ArrayList<>(); + + // Create multiple valid records first + for (int i = 0; i < RECORD_COUNT / 4; i++) { + PIDRecord record = new PIDRecordBuilder() + .withSeed((long) i) + .completeProfile() + .build(); + records.add(record); + } + + // Add duplicate PIDs to trigger failure + String duplicatePid = "sandboxed/duplicate-test-pid"; + + PIDRecord record1 = new PIDRecordBuilder() + .completeProfile() + .withPid(duplicatePid) + .build(); + records.add(record1); + + PIDRecord record2 = new PIDRecordBuilder() + .completeProfile() + .withPid(duplicatePid) + .build(); + records.add(record2); + + String jsonContent = mapper.writeValueAsString(records); + + // This should fail due to duplicate PIDs and trigger rollback + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + // Verify no records were persisted + assertEquals(0, knownPidsDao.count(), "Rollback should prevent any records from being stored due to duplicate PIDs"); + } +} \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java b/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java index 7bd3200c..ca6ff104 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java @@ -4,6 +4,10 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import edu.kit.datamanager.pit.SpringTestHelper; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; +import edu.kit.datamanager.pit.pidsystem.impl.local.LocalPidSystem; import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -58,6 +62,11 @@ class EtagTest { void setup() throws Exception { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build(); + SpringTestHelper springTestHelper = new SpringTestHelper(this.webApplicationContext); + springTestHelper.assertSingleBeanInstanceOf(InMemoryIdentifierSystem.class); + springTestHelper.assertNoBeanInstanceOf(LocalPidSystem.class); + springTestHelper.assertNoBeanInstanceOf(HandleProtocolAdapter.class); + MockHttpServletResponse response = ApiMockUtils.registerSomeRecordAndReturnMvcResult(this.mockMvc).getResponse(); String etagHeader = response.getHeader(HttpHeaders.ETAG); this.existingRecord = ApiMockUtils.deserializeRecord(response); diff --git a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java index b8662f4f..360893e1 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java @@ -1,9 +1,6 @@ package edu.kit.datamanager.pit.web; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.servlet.ServletContext; import com.fasterxml.jackson.databind.ObjectMapper; @@ -24,7 +21,7 @@ import edu.kit.datamanager.pit.domain.SimplePidRecord; import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; import edu.kit.datamanager.pit.pidlog.KnownPidsDao; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import edu.kit.datamanager.pit.pitservice.ITypingService; import edu.kit.datamanager.pit.pitservice.impl.NoValidationStrategy; @@ -44,18 +41,19 @@ import org.hamcrest.Matchers; +import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; /** * This is a dedicated test for the validation/dryrun parameters, available for the REST interface. - * + *

* It ensures that: * - validation is being executed * - no data is stored - * + *

* It uses the in-memory implementation for simplicity. - * + *

* Explicit validation parameters are: * - dryrun=true for creating a PID * - validation=true for resolving a PID @@ -70,8 +68,7 @@ class ExplicitValidationParametersTest { static final String EMPTY_RECORD = "{\"pid\": null, \"entries\": {}}"; - static final String RECORD = "{\"entries\":{\"21.T11148/076759916209e5d62bd5\":[{\"key\":\"21.T11148/076759916209e5d62bd5\",\"name\":\"kernelInformationProfile\",\"value\":\"21.T11148/301c6f04763a16f0f72a\"}],\"21.T11148/397d831aa3a9d18eb52c\":[{\"key\":\"21.T11148/397d831aa3a9d18eb52c\",\"name\":\"dateModified\",\"value\":\"2021-12-21T17:36:09.541+00:00\"}],\"21.T11148/8074aed799118ac263ad\":[{\"key\":\"21.T11148/8074aed799118ac263ad\",\"name\":\"digitalObjectPolicy\",\"value\":\"21.T11148/37d0f4689c6ea3301787\"}],\"21.T11148/92e200311a56800b3e47\":[{\"key\":\"21.T11148/92e200311a56800b3e47\",\"name\":\"etag\",\"value\":\"{ \\\"sha256sum\\\": \\\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\\\" }\"}],\"21.T11148/aafd5fb4c7222e2d950a\":[{\"key\":\"21.T11148/aafd5fb4c7222e2d950a\",\"name\":\"dateCreated\",\"value\":\"2021-12-21T17:36:09.541+00:00\"}],\"21.T11148/b8457812905b83046284\":[{\"key\":\"21.T11148/b8457812905b83046284\",\"name\":\"digitalObjectLocation\",\"value\":\"https://test.repo/file001\"}],\"21.T11148/c692273deb2772da307f\":[{\"key\":\"21.T11148/c692273deb2772da307f\",\"name\":\"version\",\"value\":\"1.0.0\"}],\"21.T11148/c83481d4bf467110e7c9\":[{\"key\":\"21.T11148/c83481d4bf467110e7c9\",\"name\":\"digitalObjectType\",\"value\":\"21.T11148/ManuscriptPage\"}]},\"pid\":\"unregistered-18622\"}"; - + @Autowired private WebApplicationContext webApplicationContext; @@ -81,6 +78,9 @@ class ExplicitValidationParametersTest { @Autowired ITypingService typingService; + @Autowired + ITypeRegistry typeRegistry; + @Autowired private ApplicationProperties appProps; @@ -93,10 +93,11 @@ class ExplicitValidationParametersTest { private InMemoryIdentifierSystem inMemory; @BeforeEach - void setup() throws Exception { + void setup() { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build(); this.knownPidsDao.deleteAll(); - this.typingService.setValidationStrategy(this.appProps.defaultValidationStrategy()); + this.typingService.setValidationStrategy( + this.appProps.defaultValidationStrategy(typeRegistry)); } @Test @@ -106,7 +107,7 @@ void checkTestSetup() { assertNotNull(this.inMemory); ServletContext servletContext = webApplicationContext.getServletContext(); assertNotNull(servletContext); - assertTrue(servletContext instanceof MockServletContext); + assertInstanceOf(MockServletContext.class, servletContext); SpringTestHelper springTestHelper = new SpringTestHelper(webApplicationContext); springTestHelper.assertSingleBeanInstanceOf(ITypingRestResource.class); @@ -140,7 +141,6 @@ void testExtensiveRecordDryRun() throws Exception { // as we use an in-memory data structure, lets not make it too large. int numAttributes = 100; int numValues = 100; - assertTrue(numAttributes * numValues > 256); PIDRecord r = RecordTestHelper.getFakePidRecord(numAttributes, numValues, "sandboxed/", pidGenerator); String rJson = ApiMockUtils.serialize(r); @@ -153,7 +153,7 @@ void testExtensiveRecordDryRun() throws Exception { .content(rJson) .accept(MediaType.ALL) ) - .andDo(MockMvcResultHandlers.print()) + //.andDo(MockMvcResultHandlers.print()) // output is massive due to the large record .andExpect(MockMvcResultMatchers.status().isOk()); // instead of created (201) // no PIDs are stored with dryrun @@ -168,7 +168,6 @@ void testExtensiveRecordWithoutDryRun() throws Exception { // as we use an in-memory data structure, lets not make it too large. int numAttributes = 100; int numValues = 100; - assertTrue(numAttributes * numValues > 256); PIDRecord r = RecordTestHelper.getFakePidRecord(numAttributes, numValues, "sandboxed/", pidGenerator); String rJson = ApiMockUtils.serialize(r); @@ -181,9 +180,9 @@ void testExtensiveRecordWithoutDryRun() throws Exception { .content(rJson) .accept(MediaType.ALL) ) - .andDo(MockMvcResultHandlers.print()) + //.andDo(MockMvcResultHandlers.print()) // output is massive due to the large record .andExpect(MockMvcResultMatchers.status().isCreated()); // instead of created (201) - + // dryrun was false, so there should be a new PID known assertEquals(1, this.knownPidsDao.count()); } @@ -209,9 +208,37 @@ void testNontypeRecord() throws Exception { } @Test - void testInvalidRecordWithProfile() throws Exception { + void testRecordWithInvalidValue() throws Exception { PIDRecord r = new PIDRecord(); - r.addEntry("21.T11148/076759916209e5d62bd5", "for Testing", "21.T11148/301c6f04763a16f0f72a"); + // valid attribute key, but wrong attribute value: + String urlType = "21.T11969/e0efc41346cda4ba84ca"; + r.addEntry(urlType, "", "not a url"); + this.mockMvc + .perform( + post("/api/v1/pit/pid/") + .param("dryrun", "true") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding("utf-8") + .content(new ObjectMapper().writeValueAsString(r)) + .accept(MediaType.ALL) + ) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath( + "$.detail", + Matchers.containsString("has a non-complying value"))); + + // we store PIDs only if the PID was created successfully + assertEquals(0, this.knownPidsDao.count()); + } + + @Test + void testRecordWithAdditionalAttribute() throws Exception { + PIDRecord r = ApiMockUtils.getSomePidRecordInstance(); + r.addEntry( + "21.T11969/86963861a2b249a83b93", + "additional attribute", + "{\"image-context-name\": \"itsa'me!\", \"image-context-uri\": \"https://example.com/mario\"}"); this.mockMvc .perform( post("/api/v1/pit/pid/") @@ -222,9 +249,9 @@ void testInvalidRecordWithProfile() throws Exception { .accept(MediaType.ALL) ) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isBadRequest()); - - // we store PIDs only if the PID was created successfully + .andExpect(MockMvcResultMatchers.status().isOk()); + + // we store PIDs only if the PID was created (no dryrun) assertEquals(0, this.knownPidsDao.count()); } @@ -233,7 +260,7 @@ void testInvalidRecordWithProfile() throws Exception { void testResolvingValidRecordWithValidation() throws Exception { this.testExtensiveRecordWithoutDryRun(); assertEquals(1, knownPidsDao.count()); - String validPid = knownPidsDao.findAll().iterator().next().getPid(); + String validPid = knownPidsDao.findAll().getFirst().getPid(); this.mockMvc .perform( get("/api/v1/pit/pid/" + validPid) @@ -246,19 +273,20 @@ void testResolvingValidRecordWithValidation() throws Exception { @Test @DisplayName("Resolve a PID known to be invalid, with explicit validation.") - void testResolvingValidRecordWithValidationFail() throws Exception { + void testResolvingInvalidRecordWithValidationFail() throws Exception { + // We'll reuse the extensive record here, and validate it. + // To do so, we resolve the PID, set validate to true, and expect a validation error. + // This error must occur, as all attributes are made up. These PIDs are not registered + // and were generated using this.pidGenerator. + // note: this test disables validation... this.testExtensiveRecordWithoutDryRun(); assertEquals(1, knownPidsDao.count()); - String validPid = knownPidsDao.findAll().iterator().next().getPid(); - - PIDRecord r = inMemory.queryAllProperties(validPid); - r.addEntry("21.T11148/076759916209e5d62bd5", "", "21.T11148/b9b76f887845e32d29f7"); - r.addEntry("something wrong", "", "someVeryUniqueValue"); - inMemory.updatePID(r); + String validPid = knownPidsDao.findAll().getFirst().getPid(); // ... so we need to re-enable validation here: - this.typingService.setValidationStrategy(this.appProps.defaultValidationStrategy()); - + this.typingService.setValidationStrategy( + this.appProps.defaultValidationStrategy(typeRegistry)); + // Now, we can resolve and validate: MvcResult result = this.mockMvc .perform( get("/api/v1/pit/pid/" + validPid) @@ -267,8 +295,8 @@ void testResolvingValidRecordWithValidationFail() throws Exception { ) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isBadRequest()) - .andExpect(MockMvcResultMatchers.jsonPath("$.detail", Matchers.containsString("Missing mandatory types: ["))) + .andExpect(MockMvcResultMatchers.jsonPath("$.detail", Matchers.containsString("Type not found"))) .andReturn(); - assertTrue(0 < result.getResponse().getContentAsString().length()); + assertFalse(result.getResponse().getContentAsString().isEmpty()); } } diff --git a/src/test/java/edu/kit/datamanager/pit/web/ITypingRestResourceTest.java b/src/test/java/edu/kit/datamanager/pit/web/ITypingRestResourceTest.java index 4a61c79f..00ea561d 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ITypingRestResourceTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ITypingRestResourceTest.java @@ -37,7 +37,7 @@ class ITypingRestResourceTest { private MockMvc mockMvc; @BeforeEach - void setup() throws Exception { + void setup() { this.mockMvc = MockMvcBuilders .webAppContextSetup(this.webApplicationContext) .build(); @@ -48,11 +48,9 @@ void setup() throws Exception { /** * Tests if the swagger ui and openapi definition is accessible. - * + *

* Note that this test is using mockMVC; it does probably not detect issues with * CSRF, but will recognize other kinds of internal issues. - * - * @throws Exception */ @Test void getOpenApiDefinition() throws Exception { diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java new file mode 100644 index 00000000..e6ca3bbb --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.kit.datamanager.pit.web; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; +import java.util.Random; +import java.util.UUID; + +/** + * Builder class for creating Persistent Identifier (PID) strings. + * + *

This class facilitates the generation of PIDs in various formats for testing purposes. It can create + * valid PIDs conforming to standard patterns, as well as intentionally invalid PIDs for negative testing + * scenarios. The builder uses a seed-based approach to generate deterministic PIDs, allowing for + * reproducible test cases.

+ * + *

A PID consists of two parts separated by a slash: + *

    + *
  • prefix: typically identifies the naming authority or domain (e.g., "sandboxed")
  • + *
  • suffix: a unique identifier (typically a UUID)
  • + *
+ * + *

Example usage:

+ *
+ *   // Create a valid PID
+ *   PIDBuilder builder = new PIDBuilder();
+ *   String validPid = builder.build(); // Results in something like "sandboxed/550e8400-e29b-41d4-a716-446655440000"
+ *
+ *   // Create a PID with custom parts
+ *   String customPid = new PIDBuilder()
+ *       .withPrefix("test-authority")
+ *       .withSuffix("custom-id-123")
+ *       .build(); // Results in "test-authority/custom-id-123"
+ *
+ *   // Create an invalid PID for testing error handling
+ *   String invalidPid = new PIDBuilder()
+ *       .invalidCharactersPrefix()
+ *       .build();
+ * 
+ */ +public class PIDBuilder implements Cloneable { + /** + * The seed value used for the random generator to create deterministic PIDs. + * This enables reproducible test scenarios with consistent PID generation. + */ + Long seed; + + /** + * Random number generator initialized with the seed value. + * Used for generating random components of PIDs in a deterministic way. + */ + private Random random; + + /** + * The prefix part of the PID, representing the naming authority or domain. + * For example, "sandboxed" or "0.NA". + */ + private String prefix; + + /** + * The suffix part of the PID, representing the unique identifier. + * Typically a UUID or other unique string. + */ + private String suffix; + + /** + * Creates a new PIDBuilder with a random seed. + * The builder is initialized with valid default prefix and suffix values. + */ + public PIDBuilder() { + this(new Random().nextLong()); + } + + /** + * Creates a new PIDBuilder with the specified seed. + * The builder is initialized with valid default prefix and suffix values. + * Using a specific seed allows for reproducible PID generation across test runs. + * + * @param seed The seed value for the random generator + */ + public PIDBuilder(Long seed) { + this.seed = seed; + this.random = new Random(seed); + + // Default values + this.validPrefix(); + this.validSuffix(); + } + + /** + * Generates a deterministic UUID based on a seed string. + * This method uses SHA-1 to hash the seed string and constructs a UUID from the hash. + * The resulting UUID is consistent for the same input seed. + * + * @param seed The seed string to generate the UUID from + * @return A UUID derived from the hash of the seed string + * @throws RuntimeException If the SHA-1 algorithm is not available + */ + private static UUID generateUUID(String seed) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(seed.getBytes(StandardCharsets.UTF_8)); + long msb = 0; + long lsb = 0; + for (int i = 0; i < 8; i++) { + msb = (msb << 8) | (hash[i] & 0xff); + } + for (int i = 8; i < 16; i++) { + lsb = (lsb << 8) | (hash[i] & 0xff); + } + return new UUID(msb, lsb); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + /** + * Sets a new seed value for this builder and reinitializes the random generator. + * This allows changing the deterministic behavior of the builder after creation. + * + * @param seed The new seed value + * @return This builder instance for method chaining + */ + public PIDBuilder withSeed(Long seed) { + this.seed = seed; + this.random = new Random(seed); + return this; + } + + /** + * Sets a custom prefix for the PID. + * The prefix typically represents the naming authority or domain. + * + * @param prefix The prefix string to use + * @return This builder instance for method chaining + */ + public PIDBuilder withPrefix(String prefix) { + this.prefix = prefix; + return this; + } + + /** + * Builds the final PID string by combining the prefix and suffix with a separator. + * The resulting format is "prefix/suffix". + * + * @return The complete PID string + */ + public String build() { + return prefix + "/" + suffix; + } + + /** + * Copies all properties from another PIDBuilder instance to this one. + * This method changes the current builder to match the state of the provided builder. + * + * @param builder The source PIDBuilder to copy from + * @return This builder instance for method chaining + */ + public PIDBuilder copyFrom(PIDBuilder builder) { + this.seed = builder.seed; + this.random = new Random(seed); + this.prefix = builder.prefix; + this.suffix = builder.suffix; + return this; + } + + /** + * Sets a valid prefix for testing purposes. + * The default valid prefix is "sandboxed", which is used for test environments. + * + * @return This builder instance for method chaining + */ + public PIDBuilder validPrefix() { + this.prefix = "sandboxed"; + return this; + } + + /** + * Sets a prefix that would cause authorization issues in a real environment. + * The prefix "0.NA" is typically reserved and would require special permissions. + * This is useful for testing authorization error handling. + * + * @return This builder instance for method chaining + */ + public PIDBuilder unauthorizedPrefix() { + this.prefix = "0.NA"; + return this; + } + + /** + * Sets an empty prefix, which would result in an invalid PID format. + * This is useful for testing validation error handling. + * + * @return This builder instance for method chaining + */ + public PIDBuilder emptyPrefix() { + this.prefix = ""; + return this; + } + + /** + * Generates a prefix containing invalid characters. + * Valid prefixes should match the regex pattern: ^[a-zA-Z0-9.-]+$ + * This method generates a string with random characters that deliberately + * fail this validation, which is useful for testing error handling. + * + * @return This builder instance for method chaining + */ + public PIDBuilder invalidCharactersPrefix() { + // generate a random String not fulfilling this regex: ^[a-zA-Z0-9.-]+$ + StringBuilder result = new StringBuilder(); + for (int i = 0; i < random.nextInt(256); i++) { // Random length + // generate a random character that is not a letter, number, dot or hyphen + char c; + do { + c = (char) random.nextInt(Character.MAX_VALUE); // Random character + } while (Character.isLetterOrDigit(c) || c == '.' || c == '-'); // Continue until an invalid character is found + result.append(c); + } + this.prefix = result.toString(); + return this; + } + + /** + * Sets a custom suffix for the PID. + * The suffix is the unique identifier part of the PID. + * + * @param suffix The suffix string to use + * @return This builder instance for method chaining + */ + public PIDBuilder withSuffix(String suffix) { + this.suffix = suffix; + return this; + } + + /** + * Generates a valid suffix using a UUID derived from the current seed. + * This ensures that the suffix is both valid and deterministic based on the seed value. + * The generated UUID follows the standard format (e.g., "550e8400-e29b-41d4-a716-446655440000"). + * + * @return This builder instance for method chaining + */ + public PIDBuilder validSuffix() { + // generate a UUID based on the seed + UUID uuid = generateUUID(seed.toString()); + this.suffix = uuid.toString(); + return this; + } + + /** + * Sets an empty suffix, which would result in an invalid PID format. + * This is useful for testing validation error handling. + * + * @return This builder instance for method chaining + */ + public PIDBuilder emptySuffix() { + this.suffix = ""; + return this; + } + + /** + * Generates a suffix containing invalid characters. + * Valid suffixes should match the regex pattern: ^[a-f0-9-]+$ + * This method attempts to generate a string that doesn't match this pattern, + * which is useful for testing validation error handling. + * + *

Note: The implementation appears to have a logical error as it generates + * characters that DO match the pattern rather than characters that DON'T match it. + * The condition should be reversed for correct behavior.

+ * + * @return This builder instance for method chaining + */ + public PIDBuilder invalidCharactersSuffix() { + // generate a random String not fulfilling this regex: ^[a-zA-Z0-9.-]+$ + StringBuilder result = new StringBuilder(); + for (int i = 0; i < random.nextInt(256); i++) { // Random length + // generate a random character that is not a letter, number, dot or hyphen + char c; + do { + c = (char) random.nextInt(Character.MAX_VALUE); // Random character + } while (Character.isLetterOrDigit(c) || c == '.' || c == '-'); // Continue until an invalid character is found + result.append(c); + } + this.suffix = result.toString(); + return this; + } + + /** + * Returns a string representation of this PIDBuilder instance. + * Includes the prefix, suffix, and seed values for debugging purposes. + * + * @return A string representation of this builder + */ + @Override + public String toString() { + return "PIDBuilder{" + + "prefix='" + prefix + '\'' + + ", suffix='" + suffix + '\'' + + ", seed=" + seed + + '}'; + } + + /** + * Compares this PIDBuilder to another object for equality. + * Two PIDBuilder instances are considered equal if they have the same prefix and suffix. + * Note that the seed value is intentionally not considered in the equality check. + * + * @param o The object to compare with + * @return true if the objects are equal, false otherwise + */ + @Override + public final boolean equals(Object o) { + if (!(o instanceof PIDBuilder that)) return false; + + return Objects.equals(prefix, that.prefix) && Objects.equals(suffix, that.suffix); + } + + /** + * Returns a hash code value for this PIDBuilder. + * The hash code is based on the prefix and suffix values, consistent with the equals method. + * + * @return The hash code value + */ + @Override + public int hashCode() { + int result = Objects.hashCode(prefix); + result = 31 * result + Objects.hashCode(suffix); + return result; + } + + /** + * Creates a deep clone of this PIDBuilder instance. + * The cloned builder will have the same seed, prefix, and suffix values, + * but with a new random generator instance initialized with the same seed. + * + * @return A new PIDBuilder instance with the same properties + * @throws AssertionError If cloning fails, which should never happen since PIDBuilder implements Cloneable + */ + @Override + public PIDBuilder clone() { + try { + PIDBuilder clone = (PIDBuilder) super.clone(); + clone.seed = this.seed; + clone.random = new Random(this.seed); + clone.prefix = this.prefix; + clone.suffix = this.suffix; + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } +} diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java new file mode 100644 index 00000000..93f22a55 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java @@ -0,0 +1,591 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.kit.datamanager.pit.web; + +import edu.kit.datamanager.pit.domain.PIDRecord; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; + +/** + * Builder class for creating PIDRecord instances with various configurations for testing purposes. + * + *

This class facilitates the generation of PID records with specific properties, connections to other records, + * and the addition of metadata. + * It is primarily designed for testing scenarios where different types of + * PID records are needed, including both valid and invalid configurations.

+ * + *

Key features:

+ *
    + *
  • Create records with valid or invalid data
  • + *
  • Connect multiple records in various relationship patterns
  • + *
  • Manage the internal state of records
  • + *
  • Add metadata, connections, PIDs, and generate invalid values or keys
  • + *
  • Create records that conform to or violate specific profiles
  • + *
  • Support for deterministic record generation using seeds
  • + *
+ * + *

This builder relies on the Helmholtz Kernel Information Profile + * (21.T11148/301c6f04763a16f0f72a) + * and its data types for validation and structure of the records.

+ * + *

Example usage:

+ *
+ *     // Create a basic valid record
+ *     PIDRecordBuilder builder = new PIDRecordBuilder();
+ *     PIDRecord record = builder.withPid("21.T11148/1234567890abcdef")
+ *        .completeProfile()
+ *        .build();
+ *
+ *     // Create connected records
+ *     PIDRecordBuilder builder1 = new PIDRecordBuilder();
+ *     PIDRecordBuilder builder2 = new PIDRecordBuilder();
+ *     builder1.addConnection("21.T11148/d0773859091aeb451528", true, builder2);
+ *
+ *     // Create an invalid record for testing error handling
+ *     PIDRecord invalidRecord = new PIDRecordBuilder()
+ *        .invalidKeys(3)
+ *        .invalidValues(2)
+ *        .build();
+ * 
+ */ +public class PIDRecordBuilder implements Cloneable { + /** + * Represents the current time plus one minute, truncated to milliseconds. + * Used for setting timestamps in record metadata to ensure they are in the future. + */ + private static final Instant NOW = Instant.now().plus(1, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS); + + /** + * Represents the time 24 hours before NOW, truncated to milliseconds. + * Used for setting creation dates in record metadata to ensure they are before modification dates. + */ + private static final Instant YESTERDAY = NOW.minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); + + /** + * PID key that identifies the profile type in a record. + * This key is used to specify which profile the record conforms to. + */ + private static final String PROFILE_KEY = "21.T11148/076759916209e5d62bd5"; + + /** + * PID key used for establishing "hasMetadata" relationships between records. + * This is used when one record references metadata contained in another record. + */ + private static final String HAS_METADATA_KEY = "21.T11148/d0773859091aeb451528"; + + /** + * PID key used for establishing "isMetadataFor" relationships between records. + * This is the inverse relationship of HAS_METADATA_KEY. + */ + private static final String IS_METADATA_FOR_KEY = "21.T11148/4fe7cde52629b61e3b82"; + + /** + * List of standardized PID keys that are required by the Helmholtz Kernel Information Profile. + * These keys represent mandatory metadata fields that must be present in a valid record: + * - dateCreated + * - digitalObjectPolicy + * - etag + * - dateModified + * - digitalObjectLocation + * - version + * - digitalObjectType + */ + private static final List KEYS_IN_PROFILE = new ArrayList<>(Arrays.stream(new String[]{"21.T11148/397d831aa3a9d18eb52c", "21.T11148/8074aed799118ac263ad", "21.T11148/92e200311a56800b3e47", "21.T11148/aafd5fb4c7222e2d950a", "21.T11148/b8457812905b83046284", "21.T11148/c692273deb2772da307f", "21.T11148/c83481d4bf467110e7c9"}).toList()); + + /** + * The seed value used for the random generator to create deterministic records. + * This enables reproducible test scenarios with consistent record generation. + */ + Long seed; + + /** + * Random number generator initialized with the seed value. + * Used for generating random components of records in a deterministic way. + */ + private Random random; + + /** + * The PID record being built by this builder. + * All operations performed on this builder modify this record instance. + */ + private PIDRecord record; + + /** + * Creates a new PIDRecordBuilder with default settings. + * Initializes a new record with a randomly generated PID using a new random seed. + * This is the simplest way to create a builder for basic test cases. + */ + public PIDRecordBuilder() { + this(null); + } + + /** + * Creates a new PIDRecordBuilder using the specified PID builder. + * If the provided PID builder is null, a default valid PID is generated. + * This constructor allows control over how the PID for the record is generated. + * + * @param pidBuilder PID builder to use to generate a PID for the record, or null to use a default + */ + public PIDRecordBuilder(PIDBuilder pidBuilder) { + this(pidBuilder, null); + } + + /** + * Creates a new PIDRecordBuilder with the specified PID builder and seed value. + * This constructor provides full control over both the PID generation and the randomization seed. + * + *

+ * If the seed is null, a random seed will be generated. + * If the PID builder is null, + * a default builder will be created using the seed value to ensure deterministic PID generation. + *

+ * + * @param pidBuilder PID builder to use to generate a PID for the record, or null to use a default + * @param seed Seed value for the random generator, or null to generate a random seed + */ + public PIDRecordBuilder(PIDBuilder pidBuilder, Long seed) { + this.seed = seed != null ? seed : new Random().nextLong(); + this.random = new Random(this.seed); + this.record = new PIDRecord(); + + if (pidBuilder == null) { + pidBuilder = new PIDBuilder(this.seed).validPrefix().validSuffix(); + } + this.record.setPid(pidBuilder.build()); + } + + /** + * Creates bidirectional connections between multiple PIDRecordBuilders in a fully meshed network. + * + *

This static method establishes connections between all provided builders, creating + * a network where every builder is connected to every other builder. For each pair of builders, + * two connections are established: one in each direction, using the specified keys.

+ * + *

This is particularly useful for testing complex relationship networks between PID records, + * such as metadata relationships, hierarchical structures, or cross-references.

+ * + * @param a_to_b_key The key to use for forward connections (from A to B). + * If null, the default "hasMetadata" key is used. + * @param b_to_a_key The key to use for backward connections (from B to A). + * If null, the default "isMetadataFor" key is used. + * @param allowDuplicateRelations Whether to allow multiple connections between the same builders. + * This controls the behavior when adding connections that might already exist. + * @param builders Array of PIDRecordBuilders to connect in the network. + * Must contain at least two builders. + * @return A list containing all the connected builders + * @throws IllegalArgumentException If fewer than two builders are provided + */ + public static List connectRecordBuilders(String a_to_b_key, String b_to_a_key, boolean allowDuplicateRelations, PIDRecordBuilder... builders) throws IllegalArgumentException { + if (builders.length < 2) { + throw new IllegalArgumentException("At least two builders are required for connection"); + } + + String forwardKey = a_to_b_key != null ? a_to_b_key : HAS_METADATA_KEY; + String backwardKey = b_to_a_key != null ? b_to_a_key : IS_METADATA_FOR_KEY; + + for (int i = 0; i < builders.length; i++) { + for (int j = 0; j < builders.length; j++) { + if (i != j) { + builders[i].addConnection(forwardKey, false, builders[j]); + builders[j].addConnection(backwardKey, false, builders[i]); + } + } + } + + return Arrays.asList(builders); + } + + /** + * Builds and returns the final PID record. + * + *

This method returns a clone of the internal record, which means the builder can be used + * to create multiple records with different modifications without affecting previously built records.

+ * + *

Example:

+ *
+     *   PIDRecordBuilder builder = new PIDRecordBuilder();
+     *   PIDRecord record1 = builder.completeProfile().build(); // A valid record
+     *   PIDRecord record2 = builder.invalidKeys(2).build();   // An invalid record
+     * 
+ * + * @return A new clone of the built PID record + */ + public PIDRecord build() { + return this.record.clone(); + } + + /** + * Sets a new seed value for this builder and reinitializes the random generator. + * + *

This method allows changing the deterministic behavior of the builder after creation. + * All later random operations will use the new seed, making test results reproducible + * when the same seed is used.

+ * + * @param seed The new seed value for random operations + * @return This builder instance for method chaining + */ + public PIDRecordBuilder withSeed(Long seed) { + this.seed = seed; + this.random = new Random(seed); + return this; + } + + /** + * Sets the PID (Persistent Identifier) of the record being built. + * + *

This method allows specifying a custom PID instead of using the automatically generated one. + * This is useful when you need to test with specific, known PIDs or when creating records that + * need to match existing identifiers.

+ * + * @param pid The PID string to assign to the record + * @return This builder instance for method chaining + */ + public PIDRecordBuilder withPid(String pid) { + this.record.setPid(pid); + return this; + } + + /** + * Adds connections from this record to one or more other records using a specified relationship key. + * + *

This method establishes directional connections from the current record to each of the specified + * target records. Each connection is made using the provided key, which defines the relationship type.

+ * + *

Common relationship types include:

+ *
    + *
  • "21.T11148/d0773859091aeb451528" - hasMetadata relationship
  • + *
  • "21.T11148/4fe7cde52629b61e3b82" - isMetadataFor relationship
  • + *
+ * + * @param key The key defining the relationship type for the connections + * @param replaceIdentical Whether to replace existing connections with the same key and target + * @param builders The target PIDRecordBuilders to connect to (at least one is required) + * @return This builder instance for method chaining + * @throws IllegalArgumentException If no target builders are provided + */ + public PIDRecordBuilder addConnection(String key, boolean replaceIdentical, PIDRecordBuilder... builders) throws IllegalArgumentException { + if (builders.length == 0) { + throw new IllegalArgumentException("At least one builder is required for connection"); + } + for (PIDRecordBuilder builder : builders) { + this.addNotDuplicate(key, builder.record.getPid(), "connectedPID", replaceIdentical); + } + + return this; + } + + /** + * Sets the internal record to a specified PIDRecord instance. + * + *

This method replaces the current record being built with the provided record instance. + * This is useful when you want to start with an existing record and make modifications to it, + * or when you need to restore a builder to a previous state.

+ * + * @param record The PIDRecord to use as the basis for further building + * @return This builder instance for method chaining + */ + public PIDRecordBuilder withPIDRecord(PIDRecord record) { + this.record = record; + return this; + } + + /** + * Adds all the required keys and values to make the record conform to the Helmholtz Kernel Information Profile. + * + *

This method populates the record with a complete set of valid metadata entries that fulfill + * the requirements of the profile. After calling this method, the record will be valid, according + * to the profile specifications. The added entries include:

+ * + *
    + *
  • Profile identifier
  • + *
  • Creation date (set to yesterday)
  • + *
  • Digital object policy
  • + *
  • ETag for versioning
  • + *
  • Modification date (set to the current time plus one minute)
  • + *
  • Digital object location (a generated URL)
  • + *
  • Version information
  • + *
  • Digital object type
  • + *
+ * + * @return This builder instance for method chaining, now with a valid profile-compliant record + */ + public PIDRecordBuilder completeProfile() { + this.addNotDuplicate(PROFILE_KEY, "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); + this.addNotDuplicate("21.T11148/397d831aa3a9d18eb52c", YESTERDAY.toString(), "dateCreated", true); + this.addNotDuplicate("21.T11148/8074aed799118ac263ad", "21.T11148/37d0f4689c6ea3301787", "digitalObjectPolicy", true); + this.addNotDuplicate("21.T11148/92e200311a56800b3e47", "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }", "etag", true); + this.addNotDuplicate("21.T11148/aafd5fb4c7222e2d950a", NOW.toString(), "dateModified", true); + this.addNotDuplicate("21.T11148/b8457812905b83046284", "https://test.example/file001-" + Integer.toHexString(random.nextInt()), "digitalObjectLocation", true); + this.addNotDuplicate("21.T11148/c692273deb2772da307f", "1.0.0", "version", true); + this.addNotDuplicate("21.T11148/c83481d4bf467110e7c9", "21.T11148/ManuscriptPage", "digitalObjectType", true); + return this; + } + + /** + * Creates an intentionally incomplete profile by removing mandatory keys. + * + *

This method first sets a non-standard profile key and then removes all standard profile keys + * from the record. The resulting record will intentionally fail validation against the standard + * profile, which is useful for testing error handling and validation logic.

+ * + *

Note that even an empty record is, by definition, considered incomplete with respect to + * the profile requirements. This method ensures the record specifically indicates it follows + * a different profile than the standard one.

+ * + * @return This builder instance for method chaining, now with an incomplete profile + */ + public PIDRecordBuilder incompleteProfile() { + this.addNotDuplicate(PROFILE_KEY, "21.T11148/b9b76f887845e32d29f7", "KernelInformationProfile", true); + + this.record.getEntries().keySet().forEach(key -> { + if (KEYS_IN_PROFILE.contains(key)) { + this.record.removeAllValuesOf(key); + } + }); + + return this; + } + + /** + * Adds a specified number of invalid values to the record for testing validation failure scenarios. + * + *

This method allows fine-grained control over which keys receive invalid values and how many + * invalid values are added. It can generate invalid values for specific keys or randomly select + * keys from the standard profile.

+ * + *

The behavior depends on the provided parameters:

+ *
    + *
  • If amount ≤ 0 and keys are specified: Generate invalid values for all specified keys
  • + *
  • If amount > 0 and keys.length ≥ amount: Generate invalid values for the first 'amount' keys
  • + *
  • If amount > 0 and keys.length < amount: Generate invalid values for all specified keys plus + * randomly selected keys from the profile until reaching 'amount'
  • + *
  • If amount ≤ 0 and no keys specified: Generate invalid values for all keys in the profile
  • + *
  • If amount > 0 and no keys specified: Generate 'amount' invalid values for randomly selected keys
  • + *
+ * + * @param amount The number of invalid values to add (use 0 or negative for special behavior) + * @param keys Optional specific keys for which to generate invalid values + * @return This builder instance for method chaining + */ + public PIDRecordBuilder invalidValues(int amount, String... keys) { + List keysToGenerateValuesFor = new ArrayList<>(); + if (amount == 0 && keys.length > 0) { + keysToGenerateValuesFor.addAll(Arrays.asList(keys)); + } else if (amount > 0 && keys.length >= amount) { + // add keys with limit of amount + keysToGenerateValuesFor.addAll(Arrays.asList(keys).subList(0, amount)); + } else if (amount > 0) { + // add all keys and generate values for the rest + keysToGenerateValuesFor.addAll(Arrays.asList(keys)); + for (int i = 0; i < amount - keys.length; i++) { + //Get a random key from the predefined lis + keysToGenerateValuesFor.add(KEYS_IN_PROFILE.get(random.nextInt(KEYS_IN_PROFILE.size()))); + } + } else { + // generate values for all keys + keysToGenerateValuesFor.addAll(KEYS_IN_PROFILE); + } + + for (String key : keysToGenerateValuesFor) { + this.addNotDuplicate(key, "invalid-value-" + random.nextInt(), "key", false); + } + + return this; + } + + /** + * Adds a specified number of randomly generated invalid keys to the record. + * + *

This method creates nonsensical, randomly generated keys and adds them to the record + * with random string values. This is useful for testing how systems handle records with + * unexpected or malformed keys.

+ * + *

The generated keys have the prefix "invalid-key-" followed by a random string of + * characters with a length between 5 and 256. Each key is assigned a random value string + * of 16 characters.

+ * + * @param amount The number of invalid keys to add to the record + * @return This builder instance for method chaining + */ + public PIDRecordBuilder invalidKeys(int amount) { + for (int i = 0; i < amount; i++) { + this.addNotDuplicate("invalid-key-" + generateRandomString(random.nextInt(5, 256)), generateRandomString(16), "KernelInformationProfile", false); + } + return this; + } + + /** + * Removes all entries from the record, creating an empty record while preserving the PID. + * + *

This method is useful for testing how systems handle empty records or for creating + * a clean slate before adding specific entries. The PID of the record is maintained, + * but all metadata entries are removed.

+ * + * @return This builder instance for method chaining + */ + public PIDRecordBuilder emptyRecord() { + String pid = this.record.getPid(); + this.record = new PIDRecord().withPID(pid); + return this; + } + + /** + * Sets the internal record reference to null. + * + *

This method creates an invalid state where the builder has no record to work with. + * This is primarily useful for testing null-handling and error recovery in code that uses + * this builder.

+ * + *

Note: After calling this method, most other methods that operate on the record will + * likely throw NullPointerExceptions if called.

+ * + * @return This builder instance for method chaining + */ + public PIDRecordBuilder nullRecord() { + this.record = null; + return this; + } + + /** + * Adds an entry to the record, with controls for handling duplicate entries. + * + *

This method adds a key-value entry to the record. If the key already exists in the record, + * the behavior depends on the replace parameter:

+ *
    + *
  • If replace is true: Any existing values for the key are removed before adding the new value
  • + *
  • If replace is false: The new value is added alongside existing values (may create duplicates)
  • + *
+ * + * @param key The key identifier for the entry + * @param value The value to associate with the key + * @param name The human-readable name for the entry type + * @param replace Whether to replace existing values for the key + * @return This builder instance for method chaining + */ + public PIDRecordBuilder addNotDuplicate(String key, String value, String name, boolean replace) { + if (this.record.getEntries().containsKey(key) && replace) { + this.record.removeAllValuesOf(key); + } + this.record.addEntry(key, name, value); + return this; + } + + /** + * Generates a random string of the specified length. + * + *

This utility method creates a string of random characters, using the full range of + * possible character values up to Character.MAX_VALUE. This can include characters from + * any Unicode block, control characters, surrogate pairs, etc.

+ * + *

Note that the resulting string may contain characters that are not displayable + * in all contexts or may cause issues with certain text processing systems.

+ * + * @param length The length of the random string to generate + * @return A string of random characters with the specified length + */ + private String generateRandomString(int length) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < length; i++) { + char c = (char) random.nextInt(Character.MAX_VALUE); + result.append(c); + } + return result.toString(); + } + + /** + * Compares this PIDRecordBuilder to another object for equality. + * + *

Two PIDRecordBuilder instances are considered equal if they have equivalent records. + * Note that the seed and random generator state are not considered in the equality check, + * only the record content itself.

+ * + *

This method is marked as final to prevent subclasses from changing the equality semantics.

+ * + * @param o The object to compare with + * @return true if the objects are equal, false otherwise + */ + @Override + public final boolean equals(Object o) { + if (!(o instanceof PIDRecordBuilder that)) return false; + + return Objects.equals(record, that.record); + } + + /** + * Returns a hash code value for this PIDRecordBuilder. + * + *

The hash code is based solely on the record's hash code, consistent with the equals method. + * This ensures that equal builders have the same hash code, as required by the general contract + * of the Object.hashCode method.

+ * + * @return The hash code value + */ + @Override + public int hashCode() { + return Objects.hashCode(record); + } + + /** + * Creates a deep clone of this PIDRecordBuilder. + * + *

This method creates a new PIDRecordBuilder instance with the same properties as this one, + * including:

+ *
    + *
  • The same seed value
  • + *
  • A new random generator initialized with the same seed
  • + *
  • A deep clone of the record being built
  • + *
+ * + *

This allows for creating independent copies of the builder that can be modified separately + * without affecting each other, while still maintaining the same initial state.

+ * + * @return A new PIDRecordBuilder instance with the same properties + * @throws AssertionError If cloning fails, which should never happen since PIDRecordBuilder implements Cloneable + */ + @Override + public PIDRecordBuilder clone() { + try { + PIDRecordBuilder clone = (PIDRecordBuilder) super.clone(); + clone.seed = this.seed; + clone.random = new Random(this.seed); + clone.record = this.record.clone(); + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } + + /** + * Returns a string representation of this PIDRecordBuilder. + * + *

The string includes the seed value, a reference to the random generator, + * and the string representation of the record being built. This is primarily + * useful for debugging and logging purposes.

+ * + * @return A string representation of this builder + */ + @Override + public String toString() { + return "PIDRecordBuilder{" + + "seed=" + seed + + ", random=" + random + + ", record=" + record + + '}'; + } +} diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java index 3f788b94..e1ab8b73 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java @@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockServletContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; @@ -18,7 +19,7 @@ import org.springframework.web.context.WebApplicationContext; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import static org.junit.jupiter.api.Assertions.*; @@ -89,18 +90,29 @@ void resolveSomething() throws Exception { } @Test - void testUpdateWithPidGiven() throws Exception { - String etag = this.mockMvc.perform( - get("/api/v1/pit/pid/21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b?validation=false") + void testDryrunUpdateWithPidGiven() throws Exception { + // Get a real record + String url = "/api/v1/pit/pid/21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b"; + MockHttpServletResponse response = this.mockMvc.perform( + get(url).param("validation", "false") ) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().is2xxSuccessful()) .andReturn() - .getResponse() - .getHeader("ETag"); + .getResponse(); + String etag = response.getHeader("ETag"); + + // Parse so we can edit the record easily + PIDRecord record = mapper.readValue(response.getContentAsString(), PIDRecord.class); + + // fix record, it is actually invalid... + record.removeAllValuesOf("URL"); + + // perform dryrun update this.mockMvc.perform( - put("/api/v1/pit/pid/21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b?dryrun=true") - .content("{ \"pid\": \"21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b\", \"entries\": { \"21.T11148/076759916209e5d62bd5\": [ { \"key\": \"21.T11148/076759916209e5d62bd5\", \"name\": \"kernelInformationProfile\", \"value\": \"21.T11148/b9b76f887845e32d29f7\" } ], \"21.T11148/397d831aa3a9d18eb52c\": [ { \"key\": \"21.T11148/397d831aa3a9d18eb52c\", \"name\": \"dateModified\", \"value\": \"2024-10-14T07:16:46+00:00\" } ], \"21.T11148/82e2503c49209e987740\": [ { \"key\": \"21.T11148/82e2503c49209e987740\", \"name\": \"checksum\", \"value\": \"{ \\\"sha256sum\\\": \\\"a92ad3bd2b0856b70d3f98cb2fa21964ea7f91218c46e327b65a0937c50a885c\\\" }\" } ], \"21.T11148/aafd5fb4c7222e2d950a\": [ { \"key\": \"21.T11148/aafd5fb4c7222e2d950a\", \"name\": \"dateCreated\", \"value\": \"2024-10-14T07:16:46+00:00\" } ], \"21.T11148/b8457812905b83046284\": [ { \"key\": \"21.T11148/b8457812905b83046284\", \"name\": \"digitalObjectLocation\", \"value\": \"https://paint-database.org/WRI1030197/WRI1030197-catalog-stac.json\" } ], \"21.T11148/1a73af9e7ae00182733b\": [ { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0009-0007-0235-4995\" }, { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0000-0002-2233-1041\" }, { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0000-0001-9648-4385\" }, { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0000-0002-9197-1739\" }, { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0000-0002-4705-6285\" } ], \"21.T11148/2f314c8fe5fb6a0063a8\": [ { \"key\": \"21.T11148/2f314c8fe5fb6a0063a8\", \"name\": \"licenseURL\", \"value\": \"https://cdla.dev/permissive-2-0/\" } ], \"21.T11148/1c699a5d1b4ad3ba4956\": [ { \"key\": \"21.T11148/1c699a5d1b4ad3ba4956\", \"name\": \"digitalResourceType\", \"value\": \"application/json\" } ] }}") + put(url) + .param("dryrun", "true") + .content(mapper.writeValueAsString(record)) .contentType(ContentType.APPLICATION_JSON.getMimeType()) .header("If-Match", etag) ) diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java index 33e1a2db..a78ec748 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java @@ -6,12 +6,12 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.servlet.ServletContext; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,7 +29,7 @@ import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; import edu.kit.datamanager.pit.pidlog.KnownPid; import edu.kit.datamanager.pit.pidlog.KnownPidsDao; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import edu.kit.datamanager.pit.pitservice.ITypingService; import edu.kit.datamanager.pit.pitservice.impl.NoValidationStrategy; @@ -81,6 +81,9 @@ class RestWithInMemoryTest { @Autowired ITypingService typingService; + @Autowired + ITypeRegistry typeRegistry; + @Autowired private ApplicationProperties appProps; @@ -100,7 +103,8 @@ void setup() throws Exception { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build(); this.mapper = this.webApplicationContext.getBean("OBJECT_MAPPER_BEAN", ObjectMapper.class); this.knownPidsDao.deleteAll(); - this.typingService.setValidationStrategy(this.appProps.defaultValidationStrategy()); + this.typingService.setValidationStrategy( + this.appProps.defaultValidationStrategy(typeRegistry)); } @Test @@ -145,6 +149,26 @@ void testCreateEmptyRecord() throws Exception { assertEquals(0, this.knownPidsDao.count()); } + @Test + @DisplayName("Test record with only a profile, no entries.") + void testRecordWithOnlyProfile() throws Exception { + PIDRecord incompleteRecord = new PIDRecord(); + incompleteRecord.addEntry( + "21.T11148/076759916209e5d62bd5", + "kernelInformationProfile", + "21.T11148/b9b76f887845e32d29f7"); + + String jsonContent = new ObjectMapper().writeValueAsString(incompleteRecord); + + this.mockMvc + .perform(post("/api/v1/pit/pid/") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding("utf-8") + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + @Test @DisplayName("Testing PID Records with usual/larger size, with the InMemory PID system.") void testExtensiveRecord() throws Exception { @@ -186,9 +210,12 @@ void testNontypeRecord() throws Exception { } @Test - void testInvalidRecordWithProfile() throws Exception { + void testRecordWithAdditionalAttribute() throws Exception { PIDRecord r = new PIDRecord(); - r.addEntry("21.T11148/076759916209e5d62bd5", "for Testing", "21.T11148/301c6f04763a16f0f72a"); + r.addEntry( + "21.T11969/86963861a2b249a83b93", + "additional attribute", + "{\"image-context-name\": \"itsa'me!\", \"image-context-uri\": \"https://example.com/mario\"}"); MvcResult result = this.mockMvc .perform( post("/api/v1/pit/pid/") @@ -198,13 +225,12 @@ void testInvalidRecordWithProfile() throws Exception { .accept(MediaType.ALL) ) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isBadRequest()) - .andExpect(MockMvcResultMatchers.jsonPath("$.detail", Matchers.containsString("Missing mandatory types: ["))) + .andExpect(MockMvcResultMatchers.status().isCreated()) .andReturn(); - // we store PIDs only if the PID was created successfully - assertEquals(0, this.knownPidsDao.count()); - // assume error parsed from body + // we store PIDs, if the PID was created successfully + assertEquals(1, this.knownPidsDao.count()); + // sanity check that body is not empty assertFalse(result.getResponse().getContentAsString().isEmpty()); } diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java index 96985e16..1fc07585 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.servlet.ServletContext; import com.fasterxml.jackson.databind.ObjectMapper; @@ -29,7 +30,7 @@ import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; import edu.kit.datamanager.pit.pidlog.KnownPid; import edu.kit.datamanager.pit.pidlog.KnownPidsDao; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import edu.kit.datamanager.pit.pidsystem.impl.local.LocalPidSystem; import edu.kit.datamanager.pit.pitservice.ITypingService; @@ -80,6 +81,9 @@ class RestWithLocalPidSystemTest { @Autowired ITypingService typingService; + @Autowired + ITypeRegistry typeRegistry; + @Autowired ApplicationProperties appProps; @@ -99,7 +103,8 @@ void setup() throws Exception { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build(); this.mapper = this.webApplicationContext.getBean("OBJECT_MAPPER_BEAN", ObjectMapper.class); this.knownPidsDao.deleteAll(); - this.typingService.setValidationStrategy(this.appProps.defaultValidationStrategy()); + this.typingService.setValidationStrategy( + this.appProps.defaultValidationStrategy(typeRegistry)); } @Test diff --git a/src/test/resources/test/application-doc.properties b/src/test/resources/test/application-doc.properties deleted file mode 100644 index 046fe8a6..00000000 --- a/src/test/resources/test/application-doc.properties +++ /dev/null @@ -1,23 +0,0 @@ -repo.auth.jwtSecret: test123 - -repo.messaging.binding.exchange: notifications -repo.messaging.binding.queue: notificationQueue -repo.messaging.binding.routingKeys: notification.# - -repo.schedule.rate:1000 - -spring.datasource.driver-class-name: org.h2.Driver -spring.datasource.url: jdbc:h2:mem:db_doc;DB_CLOSE_DELAY=-1 -spring.datasource.username: sa -spring.datasource.password: sa - -spring.main.allow-bean-definition-overriding:true - -logging.level.edu.kit: TRACE - -spring.mail.host=smtp.gmail.com -spring.mail.port=587 -spring.mail.username=kitdm.service@gmail.com -spring.mail.password=kjbabdrjpehrofck -spring.mail.properties.mail.smtp.auth=true -spring.mail.properties.mail.smtp.starttls.enable=true \ No newline at end of file diff --git a/src/test/resources/test/application-test.properties b/src/test/resources/test/application-test.properties index 3d789eee..7367d255 100644 --- a/src/test/resources/test/application-test.properties +++ b/src/test/resources/test/application-test.properties @@ -39,6 +39,7 @@ management.endpoints.web.exposure.include: * #logging.level.root: ERROR #logging.level.edu.kit.datamanager.doip:TRACE logging.level.edu.kit: DEBUG +#logging.level.edu.kit.datamanager.pit: TRACE #logging.level.org.springframework.transaction: TRACE logging.level.org.springframework: WARN logging.level.org.springframework.amqp: WARN @@ -124,8 +125,13 @@ pit.pidsystem.handle.baseURI = https://hdl.handle.net/ #pit.pidsystem.handle.userPassword = password #pit.pidsystem.handle.generatorPrefix = 11043.4 -pit.typeregistry.baseURI = https://dtr-test.pidconsortium.eu/ -#http://typeregistry.org/registrar +### Base URL for the DTR used. ### +# Currently, we support the DTRs of GWDG/ePIC. +pit.typeregistry.baseURI = https://typeapi.lab.pidconsortium.net +# If the attribute(s) keys/types in your PID records are not being recognized as such, please contact us. +# As a workaround, add them to this list: +pit.validation.profileKeys = {} + pit.pidsystem.implementation = IN_MEMORY pit.validation.strategy:embedded-strict