diff --git a/.gitignore b/.gitignore index d1a70ec..3b08a63 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ db/ neo4j-data/ neo4j-data-new/ +observability/ +logs/ # Ignore Gradle project-specific cache directory nbproject @@ -233,7 +235,7 @@ Temporary Items ### Gradle ### .gradle **/build/ -!src/**/build/ +!idoris/**/build/ # Ignore Gradle GUI configuration gradle-app.setting @@ -258,3 +260,4 @@ gradle-app.setting *.hprof # End of https://www.toptal.com/developers/gitignore/api/intellij+all,macos,java,gradle,webstorm+all,git!/.idea/!/db/ +*/build diff --git a/README.md b/README.md index b9b46c6..cc35271 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,6 @@ IDORIS is an **Integrated Data Type and Operations Registry with Inheritance System**. -## Cloning this repository - -This repository includes files that are stored using Git LFS. -Please install Git LFS before cloning this repository. -For more information, see https://git-lfs.com/. -Then execute the following command to clone this repository: - -``` -git lfs install -git lfs clone https://github.com/maximiliani/idoris.git -``` - ## Installation of Neo4j IDORIS relies on the Neo4j graph database. @@ -49,7 +37,7 @@ For macOS, you can install it using Homebrew: ```brew install openjdk@21```. For Fedora, you can install it using DNF: ```sudo dnf install java-21```. -Configure the [application.properties](src/main/resources/application.properties) file to contain the Neo4j API +Configure the [application.properties](idoris/main/resources/application.properties) file to contain the Neo4j API credentials. ``` @@ -58,7 +46,8 @@ logging.level.root=INFO spring.neo4j.uri=bolt://localhost:7687 spring.neo4j.authentication.username=neo4j spring.neo4j.authentication.password=superSecret -spring.data.rest.basePath=/api +# Base path for all REST endpoints +server.servlet.context-path=/api server.port=8095 idoris.validation-level=info idoris.validation-policy=strict @@ -71,3 +60,11 @@ When Neo4j is running, start IDORIS with the following command: ``` You can access the IDORIS API at http://localhost:8095/api. + +### API Documentation + +IDORIS provides comprehensive API documentation using OpenAPI/Swagger. You can access the API documentation +at http://localhost:8095/swagger-ui.html when the application is running. + +All endpoints support HATEOAS (Hypermedia as the Engine of Application State) and return HAL (Hypertext Application +Language) responses, making the API self-discoverable. diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 8046799..0000000 --- a/build.gradle +++ /dev/null @@ -1,202 +0,0 @@ -/* - * 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. - */ - -import java.text.SimpleDateFormat - -plugins { - id "java" - id "org.springframework.boot" version "3.5.0" // 3.5.0-M* - id "io.spring.dependency-management" version "1.1.7" - id "io.freefair.lombok" version "8.13.1" - id "io.freefair.maven-publish-java" version "8.13.1" - id "org.owasp.dependencycheck" version "12.1.1" - id "org.asciidoctor.jvm.convert" version "4.0.4" - id "net.ltgt.errorprone" version "4.2.0" - id "net.researchgate.release" version "3.1.0" - id "com.gorylenko.gradle-git-properties" version "2.5.0" - id "jacoco" - id "com.github.ben-manes.versions" version "0.52.0" -} - -description = 'IDORIS - An Integrated Data Type and Operations Registry with Inheritance System' -group = 'edu.kit.datamanager' -version = '0.0.2-SNAPSHOT' - -// Update source/target compatibility syntax -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -configurations { -// annotationProcessorPath - - compileOnly { - extendsFrom annotationProcessor - } -} - -repositories { - mavenLocal() - mavenCentral() - maven { - url = uri("https://repo.spring.io/milestone") - } -} - -ext { - springBootVersion = "3.5.0" - springDocVersion = "2.8.8" - errorproneVersion = "2.38.0" - errorproneJavacVersion = "9+181-r4173-1" // keep until a newer tag is published - httpClientVersion = "5.5" - javersVersion = "7.3.7" // unchanged (latest) - set("snippetsDir", file("build/generated-snippets")) -} - -dependencies { - /* Spring BOM – drives every spring-boot starter below */ - implementation platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}") - - /* Rules API */ - implementation project(':rules-api') - - /* Spring Boot starters (version comes from the BOM) */ - implementation "org.springframework.boot:spring-boot-starter-web" - implementation "org.springframework.boot:spring-boot-starter-data-neo4j" - implementation "org.springframework.boot:spring-boot-starter-data-rest" - implementation "org.springframework.boot:spring-boot-starter-actuator" - implementation "org.springframework.boot:spring-boot-starter-hateoas" - implementation "org.springframework.boot:spring-boot-starter-validation" - implementation "org.springframework:spring-web" - implementation "org.springframework.data:spring-data-rest-hal-explorer:5.0.0-M3" - - /* OpenAPI */ - implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springDocVersion}" - implementation "org.springdoc:springdoc-openapi-starter-webmvc-api:${springDocVersion}" - implementation "org.springdoc:springdoc-openapi-starter-common:${springDocVersion}" - - /* HTTP client */ - implementation "org.apache.httpcomponents.client5:httpclient5:${httpClientVersion}" - - /* Development helpers */ - implementation "org.springframework.boot:spring-boot-configuration-processor" - developmentOnly "org.springframework.boot:spring-boot-devtools" - - /* Lombok */ - compileOnly "org.projectlombok:lombok:1.18.38" - annotationProcessor "org.projectlombok:lombok:1.18.38" - - /* JavaX Annotations */ - implementation 'javax.annotation:javax.annotation-api:1.3.2' - - // Add the processor module - annotationProcessor project(':rules-processor') - - /* Error-prone */ - errorprone "com.google.errorprone:error_prone_core:${errorproneVersion}" - annotationProcessor "com.google.errorprone:error_prone_core:${errorproneVersion}" - - /* Tests */ - testImplementation "org.springframework.boot:spring-boot-starter-test" - testImplementation "org.springframework.restdocs:spring-restdocs-mockmvc:3.0.3" - testImplementation "org.springframework.security:spring-security-test" - testImplementation "org.junit.jupiter:junit-jupiter:5.13.0" -} - -// Modify JavaCompile tasks configuration -tasks.withType(JavaCompile).configureEach { - if (name.toLowerCase().contains('test')) { - options.errorprone.enabled = false - } - - options.compilerArgs += [ - '-Xlint:unchecked', - '-Xlint:deprecation', - '-Xmaxwarns', '200' - ] - - // Enable annotation processing explicitly - options.fork = true -// options.forkOptions.jvmArgs += [ -// '-verbose', -//// '--add-opens', 'java.base/java.lang=ALL-UNNAMED', -//// '--add-opens', 'java.base/java.util=ALL-UNNAMED' -// ] - - options.errorprone { - disableWarningsInGeneratedCode = true - } -} - -// Update test configuration -tasks.named('test') { - useJUnitPlatform() - outputs.dir snippetsDir - finalizedBy tasks.named('jacocoTestReport') - - jvmArgs = ['-Xmx4g'] // Replace maxHeapSize - - environment('spring.config.location', 'optional:classpath:/test-config/') - - testLogging { - events = ['started', 'passed', 'skipped', 'failed'] - showStandardStreams = true - outputs.upToDateWhen { false } - } -} - -tasks.named('asciidoctor') { - inputs.dir snippetsDir - dependsOn test - - attributes = [ - 'snippets': snippetsDir, - 'version' : jar.archiveVersion, - 'date' : new SimpleDateFormat("yyyy-MM-dd").format(new Date()) - ] - - sourceDir file("docs/") - outputDir = file("build/generated-docs") - - options doctype: 'book' - - forkOptions { - jvmArgs = [ - "--add-opens", "java.base/sun.nio.ch=ALL-UNNAMED", - "--add-opens", "java.base/java.io=ALL-UNNAMED" - ] - } -} - -// Update jar configuration -tasks.named('jar') { - enabled = false -} - -// Update bootJar configuration -bootJar { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - manifest { - attributes('Main-Class': 'org.springframework.boot.loader.launch.PropertiesLauncher') - } - dependsOn asciidoctor - from("${asciidoctor.outputDir}") { - into 'static/docs' - } - launchScript() -} \ No newline at end of file diff --git a/catalog-info.yaml b/catalog-info.yaml index d0a6c11..65bbed0 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -19,7 +19,7 @@ metadata: spec: lifecycle: experimental owner: user:maximiliani - type: service + type: logic domain: fairdo --- @@ -28,11 +28,10 @@ apiVersion: backstage.io/v1alpha1 kind: API metadata: name: idoris-rest-api - description: A placeholder for the HATEOAS compliant REST API of IDORIS + description: The HATEOAS compliant REST API of IDORIS tags: - rest - hateoas - - alps - openapi spec: type: openapi @@ -40,15 +39,7 @@ spec: owner: user:maximiliani system: idoris definition: | - openapi: "3.0.0" - info: - version: 0.0.1 - title: IDORIS API - license: - name: Apache 2.0 - servers: - - url: http://localhost:8095/api - paths: + {"openapi":"3.1.0","info":{"title":"IDORIS API","description":"API for the Integrated Data Type and Operations Registry with Inheritance System (IDORIS). This API provides endpoints for managing data types, operations, and their relationships within IDORIS.","contact":{"name":"KIT Data Manager Team","url":"https://kit-data-manager.github.io/webpage","email":"webmaster@datamanager.kit.edu"},"version":"0.2.0"},"externalDocs":{"description":"IDORIS GitHub Repository","url":"https://github.com/maximiliani/idoris"},"servers":[{"url":"http://localhost:8095/api","description":"Generated server url"}],"tags":[{"name":"TypeProfile","description":"API for managing TypeProfiles"},{"name":"TechnologyInterface","description":"API for managing TechnologyInterfaces"},{"name":"Operation","description":"API for managing Operations"},{"name":"AtomicDataType","description":"API for managing AtomicDataTypes"},{"name":"Actuator","description":"Monitor and interact","externalDocs":{"description":"Spring Boot Actuator Web API Documentation","url":"https://docs.spring.io/spring-boot/docs/current/actuator-api/html/"}},{"name":"Attribute","description":"API for managing Attributes"}],"paths":{"/v1/typeProfiles/{pid}":{"get":{"tags":["TypeProfile"],"summary":"Get a TypeProfile by PID","description":"Returns a TypeProfile entity by its PID","operationId":"getTypeProfile","parameters":[{"name":"pid","in":"path","description":"PID of the TypeProfile","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"TypeProfile found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TypeProfile"}}}},"404":{"description":"TypeProfile not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTypeProfile"}}}}}},"put":{"tags":["TypeProfile"],"summary":"Update a TypeProfile","description":"Updates an existing TypeProfile entity after validating it","operationId":"updateTypeProfile","parameters":[{"name":"pid","in":"path","description":"PID of the TypeProfile","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TypeProfile"}}},"required":true},"responses":{"200":{"description":"TypeProfile updated","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TypeProfile"}}}},"400":{"description":"Invalid input or validation failed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTypeProfile"}}}},"404":{"description":"TypeProfile not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTypeProfile"}}}}}},"delete":{"tags":["TypeProfile"],"summary":"Delete a TypeProfile","description":"Deletes a TypeProfile entity","operationId":"deleteTypeProfile","parameters":[{"name":"pid","in":"path","description":"PID of the TypeProfile","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"TypeProfile deleted"},"404":{"description":"TypeProfile not found"}}},"patch":{"tags":["TypeProfile"],"summary":"Partially update a TypeProfile","description":"Updates specific fields of an existing TypeProfile entity","operationId":"patchTypeProfile","parameters":[{"name":"pid","in":"path","description":"PID of the TypeProfile","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TypeProfile"}}},"required":true},"responses":{"200":{"description":"TypeProfile patched","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TypeProfile"}}}},"400":{"description":"Invalid input","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTypeProfile"}}}},"404":{"description":"TypeProfile not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTypeProfile"}}}}}}},"/v1/technologyInterfaces/{pid}":{"get":{"tags":["TechnologyInterface"],"summary":"Get a TechnologyInterface by PID","description":"Returns a TechnologyInterface entity by its PID","operationId":"getTechnologyInterface","parameters":[{"name":"pid","in":"path","description":"PID of the TechnologyInterface","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"TechnologyInterface found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TechnologyInterface"}}}},"404":{"description":"TechnologyInterface not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTechnologyInterface"}}}}}},"put":{"tags":["TechnologyInterface"],"summary":"Update a TechnologyInterface","description":"Updates an existing TechnologyInterface entity","operationId":"updateTechnologyInterface","parameters":[{"name":"pid","in":"path","description":"PID of the TechnologyInterface","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TechnologyInterface"}}},"required":true},"responses":{"200":{"description":"TechnologyInterface updated","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TechnologyInterface"}}}},"400":{"description":"Invalid input","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTechnologyInterface"}}}},"404":{"description":"TechnologyInterface not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTechnologyInterface"}}}}}},"delete":{"tags":["TechnologyInterface"],"summary":"Delete a TechnologyInterface","description":"Deletes a TechnologyInterface entity","operationId":"deleteTechnologyInterface","parameters":[{"name":"pid","in":"path","description":"PID of the TechnologyInterface","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"TechnologyInterface deleted"},"404":{"description":"TechnologyInterface not found"}}},"patch":{"tags":["TechnologyInterface"],"summary":"Partially update a TechnologyInterface","description":"Updates specific fields of an existing TechnologyInterface entity","operationId":"patchTechnologyInterface","parameters":[{"name":"pid","in":"path","description":"PID of the TechnologyInterface","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TechnologyInterface"}}},"required":true},"responses":{"200":{"description":"TechnologyInterface patched","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TechnologyInterface"}}}},"400":{"description":"Invalid input","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTechnologyInterface"}}}},"404":{"description":"TechnologyInterface not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTechnologyInterface"}}}}}}},"/v1/operations/{pid}":{"get":{"tags":["Operation"],"summary":"Get an Operation by PID","description":"Returns an Operation entity by its PID","operationId":"getOperation","parameters":[{"name":"pid","in":"path","description":"PID of the Operation","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Operation"}}}},"404":{"description":"Operation not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelOperation"}}}}}},"put":{"tags":["Operation"],"summary":"Update an Operation","description":"Updates an existing Operation entity after validating it","operationId":"updateOperation","parameters":[{"name":"pid","in":"path","description":"PID of the Operation","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Operation"}}},"required":true},"responses":{"200":{"description":"Operation updated","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Operation"}}}},"400":{"description":"Invalid input or validation failed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelOperation"}}}},"404":{"description":"Operation not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelOperation"}}}}}},"delete":{"tags":["Operation"],"summary":"Delete an Operation","description":"Deletes an Operation entity","operationId":"deleteOperation","parameters":[{"name":"pid","in":"path","description":"PID of the Operation","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Operation deleted"},"404":{"description":"Operation not found"}}},"patch":{"tags":["Operation"],"summary":"Partially update an Operation","description":"Updates specific fields of an existing Operation entity","operationId":"patchOperation","parameters":[{"name":"pid","in":"path","description":"PID of the Operation","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Operation"}}},"required":true},"responses":{"200":{"description":"Operation patched","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Operation"}}}},"400":{"description":"Invalid input","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelOperation"}}}},"404":{"description":"Operation not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelOperation"}}}}}}},"/v1/attributes/{pid}":{"get":{"tags":["Attribute"],"summary":"Get an Attribute by PID","description":"Returns an Attribute entity by its PID","operationId":"getAttribute","parameters":[{"name":"pid","in":"path","description":"PID of the Attribute","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Attribute found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Attribute"}}}},"404":{"description":"Attribute not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAttribute"}}}}}},"put":{"tags":["Attribute"],"summary":"Update an Attribute","description":"Updates an existing Attribute entity","operationId":"updateAttribute","parameters":[{"name":"pid","in":"path","description":"PID of the Attribute","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Attribute"}}},"required":true},"responses":{"200":{"description":"Attribute updated","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Attribute"}}}},"400":{"description":"Invalid input","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAttribute"}}}},"404":{"description":"Attribute not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAttribute"}}}}}},"delete":{"tags":["Attribute"],"summary":"Delete an Attribute","description":"Deletes an Attribute entity","operationId":"deleteAttribute","parameters":[{"name":"pid","in":"path","description":"PID of the Attribute","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Attribute deleted"},"404":{"description":"Attribute not found"}}},"patch":{"tags":["Attribute"],"summary":"Partially update an Attribute","description":"Updates specific fields of an existing Attribute entity","operationId":"patchAttribute","parameters":[{"name":"pid","in":"path","description":"PID of the Attribute","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Attribute"}}},"required":true},"responses":{"200":{"description":"Attribute patched","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Attribute"}}}},"400":{"description":"Invalid input","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAttribute"}}}},"404":{"description":"Attribute not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAttribute"}}}}}}},"/v1/atomicDataTypes/{pid}":{"get":{"tags":["AtomicDataType"],"summary":"Get an AtomicDataType by PID","description":"Returns an AtomicDataType entity by its PID","operationId":"getAtomicDataType","parameters":[{"name":"pid","in":"path","description":"PID of the AtomicDataType","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"AtomicDataType found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/AtomicDataType"}}}},"404":{"description":"AtomicDataType not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAtomicDataType"}}}}}},"put":{"tags":["AtomicDataType"],"summary":"Update an AtomicDataType","description":"Updates an existing AtomicDataType entity after validating it","operationId":"updateAtomicDataType","parameters":[{"name":"pid","in":"path","description":"PID of the AtomicDataType","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AtomicDataType"}}},"required":true},"responses":{"200":{"description":"AtomicDataType updated","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/AtomicDataType"}}}},"400":{"description":"Invalid input or validation failed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAtomicDataType"}}}},"404":{"description":"AtomicDataType not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAtomicDataType"}}}}}},"delete":{"tags":["AtomicDataType"],"summary":"Delete an AtomicDataType","description":"Deletes an AtomicDataType entity","operationId":"deleteAtomicDataType","parameters":[{"name":"pid","in":"path","description":"PID of the AtomicDataType","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"AtomicDataType deleted"},"404":{"description":"AtomicDataType not found"}}},"patch":{"tags":["AtomicDataType"],"summary":"Partially update an AtomicDataType","description":"Updates specific fields of an existing AtomicDataType entity","operationId":"patchAtomicDataType","parameters":[{"name":"pid","in":"path","description":"PID of the AtomicDataType","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AtomicDataType"}}},"required":true},"responses":{"200":{"description":"AtomicDataType patched","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/AtomicDataType"}}}},"400":{"description":"Invalid input","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAtomicDataType"}}}},"404":{"description":"AtomicDataType not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAtomicDataType"}}}}}}},"/v1/typeProfiles":{"get":{"tags":["TypeProfile"],"summary":"Get all TypeProfiles","description":"Returns a collection of all TypeProfile entities","operationId":"getAllTypeProfiles","responses":{"200":{"description":"TypeProfiles found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TypeProfile"}}}}}},"post":{"tags":["TypeProfile"],"summary":"Create a new TypeProfile","description":"Creates a new TypeProfile entity after validating it","operationId":"createTypeProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TypeProfile"}}},"required":true},"responses":{"201":{"description":"TypeProfile created","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TypeProfile"}}}},"400":{"description":"Invalid input or validation failed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTypeProfile"}}}}}}},"/v1/technologyInterfaces":{"get":{"tags":["TechnologyInterface"],"summary":"Get all TechnologyInterfaces","description":"Returns a collection of all TechnologyInterface entities","operationId":"getAllTechnologyInterfaces","responses":{"200":{"description":"TechnologyInterfaces found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TechnologyInterface"}}}}}},"post":{"tags":["TechnologyInterface"],"summary":"Create a new TechnologyInterface","description":"Creates a new TechnologyInterface entity","operationId":"createTechnologyInterface","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TechnologyInterface"}}},"required":true},"responses":{"201":{"description":"TechnologyInterface created","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/TechnologyInterface"}}}},"400":{"description":"Invalid input","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTechnologyInterface"}}}}}}},"/v1/operations":{"get":{"tags":["Operation"],"summary":"Get all Operations","description":"Returns a collection of all Operation entities","operationId":"getAllOperations","responses":{"200":{"description":"Operations found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Operation"}}}}}},"post":{"tags":["Operation"],"summary":"Create a new Operation","description":"Creates a new Operation entity after validating it","operationId":"createOperation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Operation"}}},"required":true},"responses":{"201":{"description":"Operation created","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Operation"}}}},"400":{"description":"Invalid input or validation failed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelOperation"}}}}}}},"/v1/attributes":{"get":{"tags":["Attribute"],"summary":"Get all Attributes","description":"Returns a collection of all Attribute entities","operationId":"getAllAttributes","responses":{"200":{"description":"Attributes found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Attribute"}}}}}},"post":{"tags":["Attribute"],"summary":"Create a new Attribute","description":"Creates a new Attribute entity","operationId":"createAttribute","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Attribute"}}},"required":true},"responses":{"201":{"description":"Attribute created","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Attribute"}}}},"400":{"description":"Invalid input","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAttribute"}}}}}}},"/v1/atomicDataTypes":{"get":{"tags":["AtomicDataType"],"summary":"Get all AtomicDataTypes","description":"Returns a collection of all AtomicDataType entities","operationId":"getAllAtomicDataTypes","responses":{"200":{"description":"AtomicDataTypes found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/AtomicDataType"}}}}}},"post":{"tags":["AtomicDataType"],"summary":"Create a new AtomicDataType","description":"Creates a new AtomicDataType entity after validating it","operationId":"createAtomicDataType","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AtomicDataType"}}},"required":true},"responses":{"201":{"description":"AtomicDataType created","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/AtomicDataType"}}}},"400":{"description":"Invalid input or validation failed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelAtomicDataType"}}}}}}},"/actuator/loggers/{name}":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'loggers-name'","operationId":"loggerLevels","parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Not Found"}}},"post":{"tags":["Actuator"],"summary":"Actuator web endpoint 'loggers-name'","operationId":"configureLogLevel","parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"string","enum":["TRACE","DEBUG","INFO","WARN","ERROR","FATAL","OFF"]}}}},"responses":{"204":{"description":"No Content"},"400":{"description":"Bad Request"}}}},"/v1/typeProfiles/{pid}/validate":{"get":{"tags":["TypeProfile"],"summary":"Validate a TypeProfile","description":"Validates a TypeProfile entity and returns the validation result","operationId":"validate","parameters":[{"name":"pid","in":"path","description":"PID of the TypeProfile","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"TypeProfile is valid","content":{"*/*":{"schema":{"type":"object"}}}},"218":{"description":"TypeProfile is invalid","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"TypeProfile not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/v1/typeProfiles/{pid}/operations":{"get":{"tags":["TypeProfile"],"summary":"Get operations for a TypeProfile","description":"Returns a collection of operations that can be executed on a TypeProfile","operationId":"getOperationsForTypeProfile","parameters":[{"name":"pid","in":"path","description":"PID of the TypeProfile","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operations found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Operation"}}}},"404":{"description":"TypeProfile not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CollectionModelEntityModelOperation"}}}}}}},"/v1/typeProfiles/{pid}/inheritedAttributes":{"get":{"tags":["TypeProfile"],"summary":"Get inherited attributes of a TypeProfile","description":"Returns a collection of attributes inherited by a TypeProfile","operationId":"getInheritedAttributes","parameters":[{"name":"pid","in":"path","description":"PID of the TypeProfile","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Inherited attributes found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Attribute"}}}},"404":{"description":"TypeProfile not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CollectionModelEntityModelAttribute"}}}}}}},"/v1/typeProfiles/{pid}/inheritanceTree":{"get":{"tags":["TypeProfile"],"summary":"Get inheritance tree of a TypeProfile","description":"Returns the inheritance tree of a TypeProfile","operationId":"getInheritanceTree","parameters":[{"name":"pid","in":"path","description":"PID of the TypeProfile","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Inheritance tree found","content":{"application/hal+json":{}}},"404":{"description":"TypeProfile not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelTypeProfileInheritance"}}}}}}},"/v1/technologyInterfaces/{pid}/outputs":{"get":{"tags":["TechnologyInterface"],"summary":"Get outputs of a TechnologyInterface","description":"Returns a collection of outputs of a TechnologyInterface","operationId":"getOutputs","parameters":[{"name":"pid","in":"path","description":"PID of the TechnologyInterface","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Outputs found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Attribute"}}}},"404":{"description":"TechnologyInterface not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CollectionModelEntityModelAttribute"}}}}}}},"/v1/technologyInterfaces/{pid}/attributes":{"get":{"tags":["TechnologyInterface"],"summary":"Get attributes of a TechnologyInterface","description":"Returns a collection of attributes of a TechnologyInterface","operationId":"getAttributes","parameters":[{"name":"pid","in":"path","description":"PID of the TechnologyInterface","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Attributes found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Attribute"}}}},"404":{"description":"TechnologyInterface not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CollectionModelEntityModelAttribute"}}}}}}},"/v1/operations/{pid}/validate":{"get":{"tags":["Operation"],"summary":"Validate an Operation","description":"Validates an Operation entity and returns the validation result","operationId":"validate_1","parameters":[{"name":"pid","in":"path","description":"PID of the Operation","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation is valid","content":{"*/*":{"schema":{"type":"object"}}}},"218":{"description":"Operation is invalid","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Operation not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/v1/operations/search/getOperationsForDataType":{"get":{"tags":["Operation"],"summary":"Get operations for a data type","description":"Returns a collection of operations that can be executed on a data type","operationId":"getOperationsForDataType","parameters":[{"name":"pid","in":"query","description":"PID of the data type","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operations found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Operation"}}}}}}},"/v1/attributes/{pid}/dataType":{"get":{"tags":["Attribute"],"summary":"Get the DataType of an Attribute","description":"Returns the DataType of an Attribute","operationId":"getDataType","parameters":[{"name":"pid","in":"path","description":"PID of the Attribute","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"DataType found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/DataType"}}}},"404":{"description":"Attribute not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityModelDataType"}}}}}}},"/v1/atomicDataTypes/{pid}/operations":{"get":{"tags":["AtomicDataType"],"summary":"Get operations for an AtomicDataType","description":"Returns a collection of operations that can be executed on an AtomicDataType","operationId":"getOperationsForAtomicDataType","parameters":[{"name":"pid","in":"path","description":"PID of the AtomicDataType","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operations found","content":{"application/hal+json":{"schema":{"$ref":"#/components/schemas/Operation"}}}},"404":{"description":"AtomicDataType not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CollectionModelEntityModelOperation"}}}}}}},"/actuator":{"get":{"tags":["Actuator"],"summary":"Actuator root web endpoint","operationId":"links","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Link"}}}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Link"}}}},"application/json":{"schema":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Link"}}}}}}}}},"/actuator/threaddump":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'threaddump'","operationId":"threadDump","responses":{"200":{"description":"OK","content":{"text/plain;charset=UTF-8":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/scheduledtasks":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'scheduledtasks'","operationId":"scheduledTasks","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/sbom":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'sbom'","operationId":"sboms","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/sbom/{id}":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'sbom-id'","operationId":"sbom","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"object"}}}},"404":{"description":"Not Found"}}}},"/actuator/modulith":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'modulith'","operationId":"getApplicationModules","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/metrics":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'metrics'","operationId":"listNames","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/metrics/{requiredMetricName}":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'metrics-requiredMetricName'","operationId":"metric","parameters":[{"name":"requiredMetricName","in":"path","required":true,"schema":{"type":"string"}},{"name":"tag","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Not Found"}}}},"/actuator/mappings":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'mappings'","operationId":"mappings","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/loggers":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'loggers'","operationId":"loggers","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/info":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'info'","operationId":"info","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/health":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'health'","operationId":"health","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/env":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'env'","operationId":"environment","parameters":[{"name":"pattern","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/env/{toMatch}":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'env-toMatch'","operationId":"environmentEntry","parameters":[{"name":"toMatch","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Not Found"}}}},"/actuator/configprops":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'configprops'","operationId":"configurationProperties","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/configprops/{prefix}":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'configprops-prefix'","operationId":"configurationPropertiesWithPrefix","parameters":[{"name":"prefix","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Not Found"}}}},"/actuator/conditions":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'conditions'","operationId":"conditions","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/actuator/caches":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'caches'","operationId":"caches","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}},"delete":{"tags":["Actuator"],"summary":"Actuator web endpoint 'caches'","operationId":"clearCaches","responses":{"204":{"description":"No Content"}}}},"/actuator/caches/{cache}":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'caches-cache'","operationId":"cache","parameters":[{"name":"cache","in":"path","required":true,"schema":{"type":"string"}},{"name":"cacheManager","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Not Found"}}},"delete":{"tags":["Actuator"],"summary":"Actuator web endpoint 'caches-cache'","operationId":"clearCache","parameters":[{"name":"cache","in":"path","required":true,"schema":{"type":"string"}},{"name":"cacheManager","in":"query","schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"404":{"description":"Not Found"}}}},"/actuator/beans":{"get":{"tags":["Actuator"],"summary":"Actuator web endpoint 'beans'","operationId":"beans","responses":{"200":{"description":"OK","content":{"application/vnd.spring-boot.actuator.v3+json":{"schema":{"type":"object"}},"application/vnd.spring-boot.actuator.v2+json":{"schema":{"type":"object"}},"application/json":{"schema":{"type":"object"}}}}}}},"/v1/attributes/orphaned":{"delete":{"tags":["Attribute"],"summary":"Delete orphaned Attributes","description":"Deletes Attribute entities that are not referenced by any other node","operationId":"deleteOrphanedAttributes","responses":{"204":{"description":"Orphaned Attributes deleted"}}}}},"components":{"schemas":{"AtomicDataType":{"allOf":[{"$ref":"#/components/schemas/DataType"},{"type":"object","properties":{"inheritsFrom":{"$ref":"#/components/schemas/AtomicDataType"},"primitiveDataType":{"type":"string","enum":["string","integer","number","bool"]},"regularExpression":{"type":"string"},"permittedValues":{"type":"array","items":{"type":"string"},"uniqueItems":true},"forbiddenValues":{"type":"array","items":{"type":"string"},"uniqueItems":true},"minimum":{"type":"integer","format":"int32"},"maximum":{"type":"integer","format":"int32"}}}]},"Attribute":{"type":"object","properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"defaultValue":{"type":"string"},"constantValue":{"type":"string"},"lowerBoundCardinality":{"type":"integer","format":"int32"},"upperBoundCardinality":{"type":"integer","format":"int32"},"dataType":{"oneOf":[{"$ref":"#/components/schemas/AtomicDataType"},{"$ref":"#/components/schemas/TypeProfile"}]},"override":{"$ref":"#/components/schemas/Attribute"},"id":{"type":"string"}},"required":["dataType"]},"DataType":{"type":"object","discriminator":{"propertyName":"type"},"properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"type":{"type":"string","enum":["AtomicDataType","TypeProfile"]},"defaultValue":{"type":"string"},"id":{"type":"string"}}},"ORCiDUser":{"allOf":[{"$ref":"#/components/schemas/User"},{"type":"object","properties":{"orcid":{"type":"string","format":"url"}}}]},"Reference":{"type":"object","properties":{"relationType":{"type":"string"},"targetPID":{"type":"string"}}},"TextUser":{"allOf":[{"$ref":"#/components/schemas/User"},{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"},"details":{"type":"string"}}}]},"TypeProfile":{"allOf":[{"$ref":"#/components/schemas/DataType"},{"type":"object","properties":{"inheritsFrom":{"type":"array","items":{"$ref":"#/components/schemas/TypeProfile"},"uniqueItems":true},"attributes":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"permitEmbedding":{"type":"boolean"},"allowAdditionalAttributes":{"type":"boolean"},"validationPolicy":{"type":"string","enum":["NONE","ONE","ANY","ALL"]},"abstract":{"type":"boolean"}}}]},"User":{"type":"object","discriminator":{"propertyName":"type"},"properties":{"createdAt":{"type":"string","format":"date-time"},"internalId":{"type":"string"},"type":{"type":"string"}}},"EntityModelTypeProfile":{"type":"object","properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"type":{"type":"string","enum":["AtomicDataType","TypeProfile"]},"defaultValue":{"type":"string"},"inheritsFrom":{"type":"array","items":{"$ref":"#/components/schemas/TypeProfile"},"uniqueItems":true},"attributes":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"permitEmbedding":{"type":"boolean"},"allowAdditionalAttributes":{"type":"boolean"},"validationPolicy":{"type":"string","enum":["NONE","ONE","ANY","ALL"]},"abstract":{"type":"boolean"},"id":{"type":"string"},"_links":{"$ref":"#/components/schemas/Links"}}},"Link":{"type":"object","properties":{"href":{"type":"string"},"hreflang":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"deprecation":{"type":"string"},"profile":{"type":"string"},"name":{"type":"string"},"templated":{"type":"boolean"}}},"TechnologyInterface":{"type":"object","properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"attributes":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"adapters":{"type":"array","items":{"type":"string"},"uniqueItems":true},"id":{"type":"string"}}},"EntityModelTechnologyInterface":{"type":"object","properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"attributes":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"adapters":{"type":"array","items":{"type":"string"},"uniqueItems":true},"id":{"type":"string"},"_links":{"$ref":"#/components/schemas/Links"}}},"AttributeMapping":{"type":"object","properties":{"internalId":{"type":"string"},"name":{"type":"string"},"input":{"$ref":"#/components/schemas/Attribute"},"replaceCharactersInValueWithInput":{"type":"string"},"value":{"type":"string"},"index":{"type":"integer","format":"int32"},"output":{"$ref":"#/components/schemas/Attribute"},"id":{"type":"string"}}},"Operation":{"type":"object","properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"executableOn":{"$ref":"#/components/schemas/Attribute"},"returns":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"environment":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"execution":{"type":"array","items":{"$ref":"#/components/schemas/OperationStep"}},"id":{"type":"string"}}},"OperationStep":{"type":"object","properties":{"internalId":{"type":"string"},"index":{"type":"integer","format":"int32"},"name":{"type":"string"},"mode":{"type":"string","enum":["sync","async"]},"subSteps":{"type":"array","items":{"$ref":"#/components/schemas/OperationStep"}},"executeOperation":{"$ref":"#/components/schemas/Operation"},"useTechnology":{"$ref":"#/components/schemas/TechnologyInterface"},"inputMappings":{"type":"array","items":{"$ref":"#/components/schemas/AttributeMapping"}},"outputMappings":{"type":"array","items":{"$ref":"#/components/schemas/AttributeMapping"}},"id":{"type":"string"}}},"EntityModelOperation":{"type":"object","properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"executableOn":{"$ref":"#/components/schemas/Attribute"},"returns":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"environment":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"},"uniqueItems":true},"execution":{"type":"array","items":{"$ref":"#/components/schemas/OperationStep"}},"id":{"type":"string"},"_links":{"$ref":"#/components/schemas/Links"}}},"EntityModelAttribute":{"type":"object","properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"defaultValue":{"type":"string"},"constantValue":{"type":"string"},"lowerBoundCardinality":{"type":"integer","format":"int32"},"upperBoundCardinality":{"type":"integer","format":"int32"},"dataType":{"oneOf":[{"$ref":"#/components/schemas/AtomicDataType"},{"$ref":"#/components/schemas/TypeProfile"}]},"override":{"$ref":"#/components/schemas/Attribute"},"id":{"type":"string"},"_links":{"$ref":"#/components/schemas/Links"}},"required":["dataType"]},"EntityModelAtomicDataType":{"type":"object","properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"type":{"type":"string","enum":["AtomicDataType","TypeProfile"]},"defaultValue":{"type":"string"},"inheritsFrom":{"$ref":"#/components/schemas/AtomicDataType"},"primitiveDataType":{"type":"string","enum":["string","integer","number","bool"]},"regularExpression":{"type":"string"},"permittedValues":{"type":"array","items":{"type":"string"},"uniqueItems":true},"forbiddenValues":{"type":"array","items":{"type":"string"},"uniqueItems":true},"minimum":{"type":"integer","format":"int32"},"maximum":{"type":"integer","format":"int32"},"id":{"type":"string"},"_links":{"$ref":"#/components/schemas/Links"}}},"CollectionModelEntityModelOperation":{"type":"object","properties":{"_embedded":{"type":"object","properties":{"operationList":{"type":"array","items":{"$ref":"#/components/schemas/EntityModelOperation"}}}},"_links":{"$ref":"#/components/schemas/Links"}}},"CollectionModelEntityModelAttribute":{"type":"object","properties":{"_embedded":{"type":"object","properties":{"attributeList":{"type":"array","items":{"$ref":"#/components/schemas/EntityModelAttribute"}}}},"_links":{"$ref":"#/components/schemas/Links"}}},"CollectionModelEntityModelTypeProfileInheritance":{"type":"object","properties":{"_embedded":{"type":"object","properties":{"typeProfileInheritanceList":{"type":"array","items":{"$ref":"#/components/schemas/EntityModelTypeProfileInheritance"}}}},"_links":{"$ref":"#/components/schemas/Links"}}},"EntityModelTypeProfileInheritance":{"type":"object","properties":{"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"attributes":{"$ref":"#/components/schemas/CollectionModelEntityModelAttribute"},"inheritsFrom":{"$ref":"#/components/schemas/CollectionModelEntityModelTypeProfileInheritance"},"_links":{"$ref":"#/components/schemas/Links"}}},"EntityModelDataType":{"type":"object","properties":{"internalId":{"type":"string"},"pid":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"version":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"lastModifiedAt":{"type":"string","format":"date-time"},"expectedUseCases":{"type":"array","items":{"type":"string"},"uniqueItems":true},"contributors":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ORCiDUser"},{"$ref":"#/components/schemas/TextUser"}]},"uniqueItems":true},"references":{"type":"array","items":{"$ref":"#/components/schemas/Reference"},"uniqueItems":true},"type":{"type":"string","enum":["AtomicDataType","TypeProfile"]},"defaultValue":{"type":"string"},"id":{"type":"string"},"_links":{"$ref":"#/components/schemas/Links"}}},"Links":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Link"}}}}} --- # https://backstage.io/docs/features/software-catalog/descriptor-format#kind-resource @@ -89,7 +80,7 @@ metadata: backstage.io/techdocs-ref: dir:. backstage.io/managed-by-origin-location: url:http://github.com/maximiliani/idoris/blob/master/catalog-info.yaml spec: - type: service + type: logic lifecycle: experimental owner: user:maximiliani system: idoris diff --git a/docker-compose-observability.yml b/docker-compose-observability.yml new file mode 100644 index 0000000..4814e99 --- /dev/null +++ b/docker-compose-observability.yml @@ -0,0 +1,142 @@ +version: '3.8' + +services: + # Prometheus for metrics collection + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + - '--web.enable-remote-write-receiver' + restart: unless-stopped + networks: + - observability-network + + # Mimir for scalable metrics storage + mimir: + image: grafana/mimir:latest + container_name: mimir + ports: + - "9009:9009" # HTTP API + - "9095:9095" # gRPC API + - "9093:9093" # Alertmanager API + volumes: + # - ./observability/mimir/mimir-config.yaml:/etc/mimir/config.yaml + - mimir_data:/data + # command: [ "-config.file=/etc/mimir/config.yaml" ] + restart: unless-stopped + networks: + - observability-network + + # Loki for log aggregation + loki: + image: grafana/loki:latest + container_name: loki + ports: + - "3100:3100" + volumes: + - ./observability/loki/loki-config.yml:/etc/loki/local-config.yaml + - loki_data:/loki + command: -config.file=/etc/loki/local-config.yaml + restart: unless-stopped + networks: + - observability-network + + # Grafana Alloy for unified observability data collection + alloy: + image: grafana/alloy:latest + container_name: alloy + volumes: + - ./observability/alloy/alloy-config.alloy:/etc/alloy/config.alloy + - /var/log:/var/log + - ./logs:/logs + - ./observability/alloy/alloy_data:/var/lib/alloy/data + command: [ "run", "--storage.path=/var/lib/alloy/data", "--server.http.listen-addr=0.0.0.0:12345", "/etc/alloy/config.alloy" ] + restart: unless-stopped + ports: + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + - "12345:12345" # Alloy UI + depends_on: + - loki + - prometheus + networks: + - observability-network + + # Tempo for distributed tracing + tempo: + image: grafana/tempo:latest + container_name: tempo + command: [ "-config.file=/etc/tempo/tempo-config.yml" ] + user: root # Run as root to avoid permission issues + volumes: + - ./observability/tempo/tempo-config.yml:/etc/tempo/tempo-config.yml + - tempo_data:/tmp/tempo + ports: + - "3200:3200" # tempo + - "4319:4319" # OTLP gRPC (changed from 4317 to avoid conflict with Alloy) + - "4320:4320" # OTLP HTTP (changed from 4318 to avoid conflict with Alloy) + - "9411:9411" # Zipkin + restart: unless-stopped + networks: + - observability-network + + # Pyroscope for continuous profiling + pyroscope: + image: grafana/pyroscope:latest + container_name: pyroscope + ports: + - "4040:4040" # HTTP API and UI + volumes: + # - ./observability/pyroscope/pyroscope-config.yaml:/etc/pyroscope/config.yaml + - pyroscope_data:/data + # command: [ "-config.file=/etc/pyroscope/config.yaml" ] + restart: unless-stopped + networks: + - observability-network + + # Grafana for visualization + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + volumes: + - ./observability/grafana/provisioning:/etc/grafana/provisioning + - ./observability/grafana/dashboards:/var/lib/grafana/dashboards + - grafana_data:/var/lib/grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + - GF_INSTALL_PLUGINS=grafana-piechart-panel + restart: unless-stopped + depends_on: + - prometheus + - mimir + - loki + - tempo + - pyroscope + networks: + - observability-network + +volumes: + prometheus_data: + mimir_data: + loki_data: + tempo_data: + pyroscope_data: + grafana_data: + +networks: + observability-network: + driver: bridge \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1819666..97fd706 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -15,7 +15,7 @@ # distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/idoris/build.gradle b/idoris/build.gradle new file mode 100644 index 0000000..204dacf --- /dev/null +++ b/idoris/build.gradle @@ -0,0 +1,236 @@ +/* + * 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. + */ + +import java.text.SimpleDateFormat + +plugins { + id "java" + id "org.springframework.boot" version "4.0.0" + id "io.spring.dependency-management" version "1.1.7" + id "io.freefair.lombok" version "9.1.0" + id "io.freefair.maven-publish-java" version "9.1.0" + id "org.owasp.dependencycheck" version "12.1.9" + id "org.asciidoctor.jvm.convert" version "4.0.5" + id "net.ltgt.errorprone" version "4.3.0" + id "net.researchgate.release" version "3.1.0" + id "com.gorylenko.gradle-git-properties" version "2.5.4" + id "jacoco" + id "com.github.ben-manes.versions" version "0.53.0" +} + +description = 'IDORIS - An Integrated Data Type and Operations Registry with Inheritance System' +group = 'edu.kit.datamanager' +version = '0.0.2-SNAPSHOT' + +// Update source/target compatibility syntax +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenLocal() + mavenCentral() +} + +ext { + springDocVersion = "3.0.0" + errorproneVersion = "2.44.0" + javersVersion = "7.3.7" + openTelemetryInstrumentationVersion = "2.22.0" + set("snippetsDir", file("build/generated-snippets")) + set("springModulithVersion", "2.0.0") + gitProperties.dotGitDirectory = file("../.git") +} + +dependencies { + /** + * Service base from KIT-Data Manager + */ +// implementation("edu.kit.datamanager:service-base:1.3.6") +// implementation("edu.kit.datamanager:repo-core:1.2.6") + + /* Rules API */ + implementation project(":rules-api") + annotationProcessor project(":rules-processor") + + /* Spring Boot starters (version comes from the BOM) */ +// implementation "org.springframework:spring-core" + implementation 'org.springframework.boot:spring-boot-starter-restclient' + implementation "org.springframework.boot:spring-boot-starter-webmvc" + implementation 'org.springframework.boot:spring-boot-starter-aspectj' + implementation 'org.springframework.boot:spring-boot-starter-amqp' +// implementation "org.springframework.boot:spring-boot-starter-data-jpa" + implementation "org.springframework.boot:spring-boot-starter-data-neo4j" +// implementation "org.springframework.boot:spring-boot-starter-data-elasticsearch" + implementation "org.springframework.boot:spring-boot-starter-hateoas" + implementation "org.springframework.boot:spring-boot-starter-validation" + implementation "org.springframework.boot:spring-boot-starter-security" + implementation "org.springframework.modulith:spring-modulith-starter-core" + implementation "org.springframework.modulith:spring-modulith-starter-neo4j" + implementation "org.springframework.modulith:spring-modulith-events-api" + implementation "org.springframework.modulith:spring-modulith-starter-insight" + runtimeOnly "org.springframework.modulith:spring-modulith-runtime" + runtimeOnly "org.springframework.modulith:spring-modulith-observability" + runtimeOnly "org.springframework.modulith:spring-modulith-actuator" + + + /* OpenAPI */ + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springDocVersion}" + implementation "org.springdoc:springdoc-openapi-starter-webmvc-api:${springDocVersion}" + implementation "org.springdoc:springdoc-openapi-starter-common:${springDocVersion}" + + /* HTTP client */ +// implementation "org.apache.httpcomponents.client5:httpclient5:${httpClientVersion}" + + /* Observability */ + implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:${openTelemetryInstrumentationVersion}")) + implementation 'org.springframework.boot:spring-boot-micrometer-tracing' + implementation 'org.springframework.boot:spring-boot-starter-micrometer-metrics' + implementation 'org.springframework.boot:spring-boot-starter-opentelemetry' + implementation "org.springframework.boot:spring-boot-starter-actuator" + implementation "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter" + implementation "io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations" +// implementation "io.opentelemetry.contrib:opentelemetry-sampler" + implementation "io.micrometer:micrometer-tracing-bridge-otel" + implementation "io.opentelemetry:opentelemetry-exporter-otlp" + + /* Development helpers */ + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + developmentOnly "org.springframework.boot:spring-boot-devtools" +// developmentOnly 'org.springframework.boot:spring-boot-docker-compose' + + /* Lombok */ + compileOnly "org.projectlombok:lombok" + annotationProcessor "org.projectlombok:lombok" + + /* Jakarta Annotations */ + implementation "jakarta.annotation:jakarta.annotation-api" + + /* Error-prone */ + errorprone "com.google.errorprone:error_prone_core:${errorproneVersion}" + annotationProcessor "com.google.errorprone:error_prone_core:${errorproneVersion}" + + /* Tests */ + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.springframework.restdocs:spring-restdocs-mockmvc" + testImplementation "org.springframework.security:spring-security-test" + testImplementation "org.springframework.modulith:spring-modulith-starter-test" + testImplementation 'org.springframework.boot:spring-boot-micrometer-tracing-test' + testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test' +// testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-data-neo4j-test' + testImplementation 'org.springframework.boot:spring-boot-starter-hateoas-test' + testImplementation 'org.springframework.boot:spring-boot-starter-opentelemetry-test' + testImplementation 'org.springframework.boot:spring-boot-starter-restclient-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-starter-amqp-test' + testImplementation 'org.springframework.boot:spring-boot-starter-aspectj-test' + testImplementation "org.junit.jupiter:junit-jupiter" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" +} + +dependencyManagement { + imports { + mavenBom "org.springframework.modulith:spring-modulith-bom:${springModulithVersion}" + mavenBom "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:${openTelemetryInstrumentationVersion}" + } +} + +// Modify JavaCompile tasks configuration +tasks.withType(JavaCompile).configureEach { + if (name.toLowerCase().contains('test')) { + options.errorprone.enabled = false + } + + options.compilerArgs += [ + '-Xlint:unchecked', + '-Xlint:deprecation', + '-Xmaxwarns', '200' + ] + + // Enable annotation processing explicitly + options.fork = true + + options.errorprone { + disableWarningsInGeneratedCode = true + } +} + +// Update test configuration +tasks.named('test') { + outputs.dir snippetsDir + useJUnitPlatform() + finalizedBy tasks.named('jacocoTestReport') + + jvmArgs = ['-Xmx4g'] // Replace maxHeapSize + + environment('spring.config.location', 'optional:classpath:/test-config/') + + testLogging { + events = ['started', 'passed', 'skipped', 'failed'] + showStandardStreams = true + outputs.upToDateWhen { false } + } +} + +tasks.named('asciidoctor') { + inputs.dir snippetsDir + dependsOn test + + attributes = [ + 'snippets': snippetsDir, + 'version' : jar.archiveVersion, + 'date' : new SimpleDateFormat("yyyy-MM-dd").format(new Date()) + ] + + sourceDir file("docs/") + outputDir = file("build/generated-docs") + + options doctype: 'book' + + forkOptions { + jvmArgs = [ + "--add-opens", "java.base/sun.nio.ch=ALL-UNNAMED", + "--add-opens", "java.base/java.io=ALL-UNNAMED" + ] + } +} + +// Update jar configuration +tasks.named('jar') { + enabled = false +} + +// Update bootJar configuration +bootJar { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + manifest { + attributes('Main-Class': 'org.springframework.boot.loader.launch.PropertiesLauncher') + } + dependsOn asciidoctor + from("${asciidoctor.outputDir}") { + into 'static/docs' + } +// launchScript() +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/idoris/IdorisApplication.java b/idoris/src/main/java/edu/kit/datamanager/idoris/Application.java similarity index 64% rename from src/main/java/edu/kit/datamanager/idoris/IdorisApplication.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/Application.java index b442ae6..4453ccf 100644 --- a/src/main/java/edu/kit/datamanager/idoris/IdorisApplication.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/Application.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Karlsruhe Institute of Technology + * Copyright (c) 2024-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. @@ -16,39 +16,40 @@ package edu.kit.datamanager.idoris; -import edu.kit.datamanager.idoris.configuration.ApplicationProperties; import lombok.extern.java.Log; import org.neo4j.cypherdsl.core.renderer.Configuration; import org.neo4j.cypherdsl.core.renderer.Dialect; 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.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.persistence.autoconfigure.EntityScan; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.data.neo4j.config.EnableNeo4jAuditing; import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories; +import org.springframework.modulith.Modulithic; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; @SpringBootApplication +@EnableScheduling +@EntityScan @EnableNeo4jRepositories @EnableNeo4jAuditing @EnableTransactionManagement -@EntityScan("edu.kit.datamanager") -@org.springframework.context.annotation.Configuration +@EnableAspectJAutoProxy +// Scans for aspects in the current package and sub-packages (e.g. for PIISpanAttribute) +@ConfigurationPropertiesScan @Log -public class IdorisApplication { - public static void main(String[] args) { - SpringApplication.run(IdorisApplication.class, args); - System.out.println(); - System.out.println("---------------------------------"); +@Modulithic(systemName = "IDORIS") +@EnableAsync +public class Application { + static void main(String[] args) { + SpringApplication.run(Application.class, args); + System.out.println("\n---------------------------------"); System.out.println("IDORIS started successfully."); - System.out.println("---------------------------------"); - } - - @Bean - @ConfigurationProperties("repo") - public ApplicationProperties applicationProperties() { - return new ApplicationProperties(); + System.out.println("---------------------------------\n"); } @Bean diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/api/IAttributeExternalService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/api/IAttributeExternalService.java new file mode 100644 index 0000000..7a567a8 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/api/IAttributeExternalService.java @@ -0,0 +1,48 @@ +/* + * 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.idoris.attributes.api; + +import edu.kit.datamanager.idoris.attributes.dto.AttributeDto; + +import java.util.List; +import java.util.Optional; + +/** + * External API for Attribute operations exposed to web/controllers and other modules. + * DTO-first contract. + */ +public interface IAttributeExternalService { + AttributeDto create(AttributeDto dto); + + AttributeDto update(String id, AttributeDto dto); + + AttributeDto patch(String id, AttributeDto dto); + + void delete(String id); + + Optional get(String id); + + List list(); + + // Relationship operations by IDs + AttributeDto setDataType(String attributeId, String dataTypeId); + + AttributeDto detachDataType(String attributeId); + + AttributeDto setOverride(String attributeId, String overrideAttributeId); + + AttributeDto detachOverride(String attributeId); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/api/IAttributeInternalService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/api/IAttributeInternalService.java new file mode 100644 index 0000000..3d3c625 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/api/IAttributeInternalService.java @@ -0,0 +1,31 @@ +/* + * 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.idoris.attributes.api; + +/** + * Internal API for Attribute operations intended for inter-module usage. + */ +public interface IAttributeInternalService { + void ensureExists(String id); + + void setDataTypeInternal(String attributeId, String dataTypeId); + + void detachDataTypeInternal(String attributeId); + + void setOverrideInternal(String attributeId, String overrideAttributeId); + + void detachOverrideInternal(String attributeId); +} diff --git a/src/test/java/edu/kit/datamanager/idoris/IdorisApplicationTests.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/api/package-info.java similarity index 66% rename from src/test/java/edu/kit/datamanager/idoris/IdorisApplicationTests.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/attributes/api/package-info.java index c45c663..f7c8ddf 100644 --- a/src/test/java/edu/kit/datamanager/idoris/IdorisApplicationTests.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/api/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Karlsruhe Institute of Technology. + * 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. @@ -14,16 +14,5 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class IdorisApplicationTests { - - @Test - void contextLoads() { - } - -} +@org.springframework.modulith.NamedInterface("attributes.services.api") +package edu.kit.datamanager.idoris.attributes.api; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/dao/IAttributeDao.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/dao/IAttributeDao.java new file mode 100644 index 0000000..4e2a474 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/dao/IAttributeDao.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2024-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.idoris.attributes.dao; + +import edu.kit.datamanager.idoris.core.dao.IGenericRepo; +import edu.kit.datamanager.idoris.core.domain.Attribute; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.data.repository.query.Param; + +public interface IAttributeDao extends IGenericRepo { + @Query("MATCH (n:Attribute)" + + " WHERE size([(n)-[:dataType]->() | 1]) = 1 AND NOT (n)<-[]-()" + + " WITH n" + + " MATCH (x)-[r]->()" + + " WHERE type(r) <> \"dataType\"" + + " WITH collect(DISTINCT x) as otherNodes, n" + + " WHERE NOT n IN otherNodes" + + " DETACH DELETE n") + void deleteOrphanedAttributes(); + + // ===== Relationship operations: dataType ===== + @Query(""" + MATCH (a:Attribute) + WHERE a.internalId = $attributeId OR EXISTS { MATCH (p:PersistentIdentifier {pid: $attributeId})-[:IDENTIFIES]->(a) } + OPTIONAL MATCH (a)-[r:dataType]->() + DELETE r + WITH a + MATCH (dt:DataType) + WHERE dt.internalId = $dataTypeId OR EXISTS { MATCH (pp:PersistentIdentifier {pid: $dataTypeId})-[:IDENTIFIES]->(dt) } + MERGE (a)-[:dataType]->(dt) + SET a.dataTypeId = dt.internalId + """) + void setDataType(@Param("attributeId") String attributeId, @Param("dataTypeId") String dataTypeId); + + @Query(""" + MATCH (a:Attribute) + WHERE a.internalId = $attributeId OR EXISTS { MATCH (p:PersistentIdentifier {pid: $attributeId})-[:IDENTIFIES]->(a) } + OPTIONAL MATCH (a)-[r:dataType]->() + DELETE r + SET a.dataTypeId = null + """) + void detachDataType(@Param("attributeId") String attributeId); + + // ===== Relationship operations: override ===== + @Query(""" + MATCH (a:Attribute) + WHERE a.internalId = $attributeId OR EXISTS { MATCH (p:PersistentIdentifier {pid: $attributeId})-[:IDENTIFIES]->(a) } + OPTIONAL MATCH (a)-[r:override]->() + DELETE r + WITH a + MATCH (b:Attribute) + WHERE b.internalId = $overrideId OR EXISTS { MATCH (pp:PersistentIdentifier {pid: $overrideId})-[:IDENTIFIES]->(b) } + MERGE (a)-[:override]->(b) + """) + void setOverride(@Param("attributeId") String attributeId, @Param("overrideId") String overrideId); + + @Query(""" + MATCH (a:Attribute) + WHERE a.internalId = $attributeId OR EXISTS { MATCH (p:PersistentIdentifier {pid: $attributeId})-[:IDENTIFIES]->(a) } + OPTIONAL MATCH (a)-[r:override]->() + DELETE r + """) + void detachOverride(@Param("attributeId") String attributeId); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/dto/AttributeDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/dto/AttributeDto.java new file mode 100644 index 0000000..1a0f75d --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/dto/AttributeDto.java @@ -0,0 +1,76 @@ +/* + * 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.idoris.attributes.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +/** + * Data Transfer Object for Attribute as a Java record. + * Contains only user-defined information and identifiers for relationships. + *

+ * Note: pidLink has been removed from DTOs. Use HATEOAS links to /pid/{pid} instead. + */ +@Builder +@Schema(name = "Attribute", description = "DTO representing an Attribute") +public record AttributeDto( + @Schema(description = "Internal ID (internal use only)") String internalId, + @Schema(description = "Name of the attribute") String name, + @Schema(description = "Description of the attribute") String description, + @Schema(description = "Default value") String defaultValue, + @Schema(description = "Constant value (if fixed)") String constantValue, + @Schema(description = "Lower bound cardinality") Integer lowerBoundCardinality, + @Schema(description = "Upper bound cardinality") Integer upperBoundCardinality, + @Schema(description = "ID of the DataType node") String dataTypeId, + @Schema(description = "ID of an Attribute this one overrides") String overrideId +) { + // JavaBean-style getters for compatibility with existing code/tests + public String getInternalId() { + return internalId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getDefaultValue() { + return defaultValue; + } + + public String getConstantValue() { + return constantValue; + } + + public Integer getLowerBoundCardinality() { + return lowerBoundCardinality; + } + + public Integer getUpperBoundCardinality() { + return upperBoundCardinality; + } + + public String getDataTypeId() { + return dataTypeId; + } + + public String getOverrideId() { + return overrideId; + } +} diff --git a/src/main/java/edu/kit/datamanager/idoris/dao/ITechnologyInterfaceDao.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/dto/package-info.java similarity index 56% rename from src/main/java/edu/kit/datamanager/idoris/dao/ITechnologyInterfaceDao.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/attributes/dto/package-info.java index b47a188..5c0405e 100644 --- a/src/main/java/edu/kit/datamanager/idoris/dao/ITechnologyInterfaceDao.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/dto/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025 Karlsruhe Institute of Technology + * 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. @@ -14,11 +14,5 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.dao; - -import edu.kit.datamanager.idoris.domain.entities.TechnologyInterface; -import org.springframework.data.rest.core.annotation.RepositoryRestResource; - -@RepositoryRestResource(collectionResourceRel = "technologyInterfaces", path = "technologyInterfaces") -public interface ITechnologyInterfaceDao extends IGenericRepo { -} +@org.springframework.modulith.NamedInterface("dto") +package edu.kit.datamanager.idoris.attributes.dto; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributeCreatedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributeCreatedEvent.java new file mode 100644 index 0000000..75a0b03 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributeCreatedEvent.java @@ -0,0 +1,38 @@ +/* + * 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.idoris.attributes.events; + +import edu.kit.datamanager.idoris.attributes.dto.AttributeDto; +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +/** + * Module-scoped Attribute created event carrying AttributeDto payload. + */ +@Getter +@ToString +public class AttributeCreatedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the Attribute") + private final String id; + private final AttributeDto payload; + + public AttributeCreatedEvent(String id, AttributeDto payload) { + this.id = id; + this.payload = payload; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributeDeletedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributeDeletedEvent.java new file mode 100644 index 0000000..775ffb0 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributeDeletedEvent.java @@ -0,0 +1,35 @@ +/* + * 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.idoris.attributes.events; + +import edu.kit.datamanager.idoris.attributes.dto.AttributeDto; +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class AttributeDeletedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the Attribute") + private final String id; + private final AttributeDto payload; + + public AttributeDeletedEvent(String id, AttributeDto payload) { + this.id = id; + this.payload = payload; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributePatchedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributePatchedEvent.java new file mode 100644 index 0000000..43c1ca1 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributePatchedEvent.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.idoris.attributes.events; + +import edu.kit.datamanager.idoris.attributes.dto.AttributeDto; +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class AttributePatchedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the Attribute") + private final String id; + private final Long previousVersion; + private final AttributeDto payload; + + public AttributePatchedEvent(String id, Long previousVersion, AttributeDto payload) { + this.id = id; + this.previousVersion = previousVersion; + this.payload = payload; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributeUpdatedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributeUpdatedEvent.java new file mode 100644 index 0000000..3fb47b7 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/AttributeUpdatedEvent.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.idoris.attributes.events; + +import edu.kit.datamanager.idoris.attributes.dto.AttributeDto; +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class AttributeUpdatedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the Attribute") + private final String id; + private final Long previousVersion; + private final AttributeDto payload; + + public AttributeUpdatedEvent(String id, Long previousVersion, AttributeDto payload) { + this.id = id; + this.previousVersion = previousVersion; + this.payload = payload; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/package-info.java new file mode 100644 index 0000000..5a094f2 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/events/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("events") +package edu.kit.datamanager.idoris.attributes.events; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/mappers/AttributeMapper.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/mappers/AttributeMapper.java new file mode 100644 index 0000000..3e06aad --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/mappers/AttributeMapper.java @@ -0,0 +1,74 @@ +/* + * 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.idoris.attributes.mappers; + +import edu.kit.datamanager.idoris.attributes.dto.AttributeDto; +import edu.kit.datamanager.idoris.core.domain.Attribute; +import edu.kit.datamanager.idoris.core.domain.valueObjects.Name; +import io.micrometer.observation.annotation.Observed; + +/** + * Mapper for Attribute entity and DTO. + * - Convert entity -> DTO exposing user fields and relationship IDs + * - Convert DTO -> entity (scalar fields only) + * - Apply partial updates (patch semantics) for scalar fields + */ +@Observed(contextualName = "attributeMapper") +@org.springframework.stereotype.Component +public class AttributeMapper { + + public AttributeDto toDto(Attribute entity) { + if (entity == null) return null; + String dataTypeId = entity.getDataTypeId(); + String overrideId = entity.getOverride() != null ? entity.getOverride().getId() : null; + return AttributeDto.builder() + .internalId(entity.getId()) + .name(entity.getName().toString()) + .description(entity.getDescription().toString()) + .defaultValue(entity.getDefaultValue()) + .constantValue(entity.getConstantValue()) + .lowerBoundCardinality(entity.getLowerBoundCardinality()) + .upperBoundCardinality(entity.getUpperBoundCardinality()) + .dataTypeId(dataTypeId) + .overrideId(overrideId) + .build(); + } + + public Attribute toEntity(AttributeDto dto) { + if (dto == null) return null; + Attribute entity = new Attribute(); + entity.setName(new Name(dto.getName())); + entity.setDescription(new Name(dto.getDescription())); + entity.setDefaultValue(dto.getDefaultValue()); + entity.setConstantValue(dto.getConstantValue()); + entity.setLowerBoundCardinality(dto.getLowerBoundCardinality()); + entity.setUpperBoundCardinality(dto.getUpperBoundCardinality()); + // Relationships (dataType/override) are linked via logic/DAO using IDs. + return entity; + } + + public Attribute applyPatch(AttributeDto dto, Attribute entity) { + if (dto == null || entity == null) return entity; + if (dto.getName() != null) entity.setName(new Name(dto.getName())); + if (dto.getDescription() != null) entity.setDescription(new Name(dto.getDescription())); + if (dto.getDefaultValue() != null) entity.setDefaultValue(dto.getDefaultValue()); + if (dto.getConstantValue() != null) entity.setConstantValue(dto.getConstantValue()); + if (dto.getLowerBoundCardinality() != null) entity.setLowerBoundCardinality(dto.getLowerBoundCardinality()); + if (dto.getUpperBoundCardinality() != null) entity.setUpperBoundCardinality(dto.getUpperBoundCardinality()); + // Relationship IDs handled elsewhere + return entity; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/package-info.java new file mode 100644 index 0000000..10478d3 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/package-info.java @@ -0,0 +1,28 @@ +/* + * 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. + */ + +/** + * Attributes module for IDORIS. + * This module contains entity definitions, domain services, and business logic related to attributes. + * It is responsible for managing attributes and attribute mappings. + * + *

The Attributes module depends on the core module for base abstractions and interfaces.

+ */ +@org.springframework.modulith.ApplicationModule( + displayName = "IDORIS Attributes", + allowedDependencies = {"core", "rules"} +) +package edu.kit.datamanager.idoris.attributes; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/services/AttributeDtoService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/services/AttributeDtoService.java new file mode 100644 index 0000000..8aa0544 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/services/AttributeDtoService.java @@ -0,0 +1,189 @@ +/* + * 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.idoris.attributes.services; + +import edu.kit.datamanager.idoris.attributes.api.IAttributeExternalService; +import edu.kit.datamanager.idoris.attributes.api.IAttributeInternalService; +import edu.kit.datamanager.idoris.attributes.dao.IAttributeDao; +import edu.kit.datamanager.idoris.attributes.dto.AttributeDto; +import edu.kit.datamanager.idoris.attributes.mappers.AttributeMapper; +import edu.kit.datamanager.idoris.core.domain.Attribute; +import edu.kit.datamanager.idoris.core.events.EventPublisherService; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * DTO-first logic for Attributes, implementing exported external and internal APIs. + * Keeps entity-based controller untouched for now; controller migration follows later. + */ +@Service +@Slf4j +@Observed(contextualName = "attributeDtoService") +public class AttributeDtoService implements IAttributeExternalService, IAttributeInternalService { + + private final IAttributeDao attributeDao; + private final EventPublisherService eventPublisher; + private final AttributeMapper mapper; + + public AttributeDtoService(IAttributeDao attributeDao, EventPublisherService eventPublisher, AttributeMapper mapper) { + this.attributeDao = attributeDao; + this.eventPublisher = eventPublisher; + this.mapper = mapper; + } + + // ===== External API ===== + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "attributeDtoService.create", description = "Time taken to create attribute DTO", histogram = true) + @Counted(value = "attributeDtoService.create.count", description = "Number of attribute DTO creations") + public AttributeDto create(@SpanAttribute AttributeDto dto) { + Attribute entity = mapper.toEntity(dto); + Attribute saved = attributeDao.save(entity); + // Publish module-scoped event + eventPublisher.publishEvent(new edu.kit.datamanager.idoris.attributes.events.AttributeCreatedEvent(saved.getId(), mapper.toDto(saved))); + // Link relationships if provided + if (dto.getDataTypeId() != null && !dto.getDataTypeId().isBlank()) { + attributeDao.setDataType(saved.getId(), dto.getDataTypeId()); + } + if (dto.getOverrideId() != null && !dto.getOverrideId().isBlank()) { + attributeDao.setOverride(saved.getId(), dto.getOverrideId()); + } + Attribute reloaded = attributeDao.findById(saved.getId()).orElse(saved); + return mapper.toDto(reloaded); + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + public AttributeDto update(String id, AttributeDto dto) { + Attribute existing = attributeDao.findById(id).orElseThrow(() -> new IllegalArgumentException("Attribute not found: " + id)); + Long previousVersion = existing.getVersion(); + mapper.applyPatch(dto, existing); // scalar fields only + Attribute saved = attributeDao.save(existing); + // Publish module-scoped updated event + eventPublisher.publishEvent(new edu.kit.datamanager.idoris.attributes.events.AttributeUpdatedEvent(saved.getId(), previousVersion, mapper.toDto(saved))); + return mapper.toDto(saved); + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + public AttributeDto patch(String id, AttributeDto dto) { + Attribute existing = attributeDao.findById(id).orElseThrow(() -> new IllegalArgumentException("Attribute not found: " + id)); + Long previousVersion = existing.getVersion(); + mapper.applyPatch(dto, existing); + Attribute saved = attributeDao.save(existing); + // Publish module-scoped patched event + eventPublisher.publishEvent(new edu.kit.datamanager.idoris.attributes.events.AttributePatchedEvent(saved.getId(), previousVersion, mapper.toDto(saved))); + return mapper.toDto(saved); + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + public void delete(@SpanAttribute("attribute.id") String id) { + Attribute existing = attributeDao.findById(id).orElseThrow(() -> new IllegalArgumentException("Attribute not found: " + id)); + // Publish module-scoped deleted event before deletion to include full payload + eventPublisher.publishEvent(new edu.kit.datamanager.idoris.attributes.events.AttributeDeletedEvent(existing.getId(), mapper.toDto(existing))); + attributeDao.delete(existing); + } + + @Override + @Transactional(readOnly = true) + public Optional get(String id) { + return attributeDao.findById(id).map(mapper::toDto); + } + + @Override + @Transactional(readOnly = true) + public List list() { + return attributeDao.findAll().stream().map(mapper::toDto).toList(); + } + + @Override + @Transactional + public AttributeDto setDataType(String attributeId, String dataTypeId) { + attributeDao.setDataType(attributeId, dataTypeId); + return get(attributeId).orElseThrow(); + } + + @Override + @Transactional + public AttributeDto detachDataType(String attributeId) { + attributeDao.detachDataType(attributeId); + return get(attributeId).orElseThrow(); + } + + @Override + @Transactional + public AttributeDto setOverride(String attributeId, String overrideAttributeId) { + attributeDao.setOverride(attributeId, overrideAttributeId); + return get(attributeId).orElseThrow(); + } + + @Override + @Transactional + public AttributeDto detachOverride(String attributeId) { + attributeDao.detachOverride(attributeId); + return get(attributeId).orElseThrow(); + } + + // ===== Internal API ===== + + @Override + @Transactional(readOnly = true) + public void ensureExists(String id) { + if (attributeDao.findById(id).isEmpty()) { + throw new IllegalArgumentException("Attribute not found: " + id); + } + } + + @Override + @Transactional + public void setDataTypeInternal(String attributeId, String dataTypeId) { + attributeDao.setDataType(attributeId, dataTypeId); + } + + @Override + @Transactional + public void detachDataTypeInternal(String attributeId) { + attributeDao.detachDataType(attributeId); + } + + @Override + @Transactional + public void setOverrideInternal(String attributeId, String overrideAttributeId) { + attributeDao.setOverride(attributeId, overrideAttributeId); + } + + @Override + @Transactional + public void detachOverrideInternal(String attributeId) { + attributeDao.detachOverride(attributeId); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/services/AttributeService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/services/AttributeService.java new file mode 100644 index 0000000..2af920d --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/services/AttributeService.java @@ -0,0 +1,228 @@ +/* + * 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.idoris.attributes.services; + +import edu.kit.datamanager.idoris.attributes.dao.IAttributeDao; +import edu.kit.datamanager.idoris.core.domain.Attribute; +import edu.kit.datamanager.idoris.core.events.EventPublisherService; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Service for managing Attribute entities. + * This logic provides methods for creating, updating, and retrieving Attribute entities. + * It publishes domain events when entities are created, updated, or deleted. + */ +@Service +@Slf4j +@Observed(contextualName = "attributeService") +public class AttributeService { + private final IAttributeDao attributeDao; + private final EventPublisherService eventPublisher; + + /** + * Creates a new AttributeService with the given dependencies. + * + * @param attributeDao the Attribute repository + * @param eventPublisher the event publisher logic + */ + public AttributeService(IAttributeDao attributeDao, EventPublisherService eventPublisher) { + this.attributeDao = attributeDao; + this.eventPublisher = eventPublisher; + } + + /** + * Creates a new Attribute entity. + * + * @param attribute the Attribute entity to create + * @return the created Attribute entity + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "attributeService.createAttribute", description = "Time taken to create an attribute", histogram = true) + @Counted(value = "attributeService.createAttribute.count", description = "Number of attribute creations") + public Attribute createAttribute(Attribute attribute) { + log.debug("Creating Attribute: {}", attribute); + Attribute saved = attributeDao.save(attribute); + eventPublisher.publishEntityCreated(saved); + log.info("Created Attribute with PID: {}", saved.getId()); + return saved; + } + + /** + * Updates an existing Attribute entity. + * + * @param attribute the Attribute entity to update + * @return the updated Attribute entity + * @throws IllegalArgumentException if the Attribute does not exist + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "attributeService.updateAttribute", description = "Time taken to update an attribute", histogram = true) + @Counted(value = "attributeService.updateAttribute.count", description = "Number of attribute updates") + public Attribute updateAttribute(Attribute attribute) { + log.debug("Updating Attribute: {}", attribute); + + if (attribute.getId() == null || attribute.getId().isEmpty()) { + throw new IllegalArgumentException("Attribute must have a PID to be updated"); + } + + // Get the current version before updating + Attribute existing = attributeDao.findById(attribute.getId()) + .orElseThrow(() -> new IllegalArgumentException("Attribute not found with PID: " + attribute.getId())); + + Long previousVersion = existing.getVersion(); + + Attribute saved = attributeDao.save(attribute); + eventPublisher.publishEntityUpdated(saved, previousVersion); + log.info("Updated Attribute with PID: {}", saved.getId()); + return saved; + } + + /** + * Deletes an Attribute entity. + * + * @param id the PID or internal ID of the Attribute to delete + * @throws IllegalArgumentException if the Attribute does not exist + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "attributeService.deleteAttribute", description = "Time taken to delete an attribute", histogram = true) + @Counted(value = "attributeService.deleteAttribute.count", description = "Number of attribute deletions") + public void deleteAttribute(@SpanAttribute("attribute.id") String id) { + log.debug("Deleting Attribute with ID: {}", id); + + Attribute attribute = attributeDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Attribute not found with ID: " + id)); + + attributeDao.delete(attribute); + eventPublisher.publishEntityDeleted(attribute); + log.info("Deleted Attribute with ID: {}", id); + } + + /** + * Retrieves an Attribute entity by its PID or internal ID. + * + * @param id the PID or internal ID of the Attribute to retrieve + * @return an Optional containing the Attribute, or empty if not found + */ + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "attributeService.getAttribute", description = "Time taken to get an attribute", histogram = true) + @Counted(value = "attributeService.getAttribute.count", description = "Number of attribute retrievals") + public Optional getAttribute(@SpanAttribute("attribute.id") String id) { + log.debug("Retrieving Attribute with ID: {}", id); + return attributeDao.findById(id); + } + + /** + * Retrieves all Attribute entities. + * + * @return a list of all Attribute entities + */ + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "attributeService.getAllAttributes", description = "Time taken to get all attributes", histogram = true) + @Counted(value = "attributeService.getAllAttributes.count", description = "Number of get all attributes requests") + public List getAllAttributes() { + log.debug("Retrieving all Attributes"); + return attributeDao.findAll(); + } + + /** + * Deletes orphaned Attribute entities. + * An orphaned Attribute is one that has a dataType relationship but is not referenced by any other node. + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "attributeService.deleteOrphanedAttributes", description = "Time taken to delete orphaned attributes", histogram = true) + @Counted(value = "attributeService.deleteOrphanedAttributes.count", description = "Number of delete orphaned attributes requests") + public void deleteOrphanedAttributes() { + log.debug("Deleting orphaned Attributes"); + attributeDao.deleteOrphanedAttributes(); + log.info("Deleted orphaned Attributes"); + } + + /** + * Partially updates an existing Attribute entity. + * + * @param id the PID or internal ID of the Attribute to patch + * @param attributePatch the partial Attribute entity with fields to update + * @return the patched Attribute entity + * @throws IllegalArgumentException if the Attribute does not exist + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "attributeService.patchAttribute", description = "Time taken to patch an attribute", histogram = true) + @Counted(value = "attributeService.patchAttribute.count", description = "Number of attribute patches") + public Attribute patchAttribute(@SpanAttribute("attribute.id") String id, Attribute attributePatch) { + log.debug("Patching Attribute with ID: {}, patch: {}", id, attributePatch); + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("Attribute ID cannot be null or empty"); + } + + // Get the current entity + Attribute existing = attributeDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Attribute not found with ID: " + id)); + Long previousVersion = existing.getVersion(); + + // Apply non-null fields from the patch to the existing entity + if (attributePatch.getName() != null) { + existing.setName(attributePatch.getName()); + } + if (attributePatch.getDescription() != null) { + existing.setDescription(attributePatch.getDescription()); + } + if (attributePatch.getDefaultValue() != null) { + existing.setDefaultValue(attributePatch.getDefaultValue()); + } + if (attributePatch.getConstantValue() != null) { + existing.setConstantValue(attributePatch.getConstantValue()); + } + if (attributePatch.getLowerBoundCardinality() != null) { + existing.setLowerBoundCardinality(attributePatch.getLowerBoundCardinality()); + } + if (attributePatch.getUpperBoundCardinality() != null) { + existing.setUpperBoundCardinality(attributePatch.getUpperBoundCardinality()); + } + if (attributePatch.getDataTypeId() != null) { + existing.setDataTypeId(attributePatch.getDataTypeId()); + } + if (attributePatch.getOverride() != null) { + existing.setOverride(attributePatch.getOverride()); + } + + // Save the updated entity + Attribute saved = attributeDao.save(existing); + + // Publish the patched event + eventPublisher.publishEntityPatched(saved, previousVersion); + + log.info("Patched Attribute with PID: {}", saved.getId()); + return saved; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/api/IAttributeApi.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/api/IAttributeApi.java new file mode 100644 index 0000000..58c854e --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/api/IAttributeApi.java @@ -0,0 +1,203 @@ +/* + * 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.idoris.attributes.web.api; + +import edu.kit.datamanager.idoris.core.domain.Attribute; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * API interface for Attribute endpoints. + * This interface defines the REST API for managing Attribute entities. + */ +@Tag(name = "Attribute", description = "API for managing Attributes") +public interface IAttributeApi { + + /** + * Gets all Attribute entities. + * + * @return a collection of all Attribute entities + */ + @GetMapping + @Operation( + summary = "Get all Attributes", + description = "Returns a collection of all Attribute entities", + responses = { + @ApiResponse(responseCode = "200", description = "Attributes found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = Attribute.class))) + } + ) + ResponseEntity>> getAllAttributes(); + + /** + * Gets an Attribute entity by its PID or internal ID. + * + * @param id the PID or internal ID of the Attribute to retrieve + * @return the Attribute entity + */ + @GetMapping("/{id}") + @Operation( + summary = "Get an Attribute by PID or internal ID", + description = "Returns an Attribute entity by its PID or internal ID", + responses = { + @ApiResponse(responseCode = "200", description = "Attribute found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = Attribute.class))), + @ApiResponse(responseCode = "404", description = "Attribute not found") + } + ) + ResponseEntity> getAttribute( + @Parameter(description = "PID or internal ID of the Attribute", required = true) + @PathVariable String id); + + /** + * Gets the DataType of an Attribute. + * + * @param id the PID or internal ID of the Attribute + * @return the DataType of the Attribute + */ + @GetMapping("/{id}/dataType") + @Operation( + summary = "Get the DataType of an Attribute", + description = "Returns the ID of the DataType of an Attribute", + responses = { + @ApiResponse(responseCode = "200", description = "DataType found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = String.class))), + @ApiResponse(responseCode = "404", description = "Attribute not found") + } + ) + ResponseEntity> getDataType( + @Parameter(description = "PID or internal ID of the Attribute", required = true) + @PathVariable String id); + + /** + * Creates a new Attribute entity. + * + * @param attribute the Attribute entity to create + * @return the created Attribute entity + */ + @PostMapping + @Operation( + summary = "Create a new Attribute", + description = "Creates a new Attribute entity", + responses = { + @ApiResponse(responseCode = "201", description = "Attribute created", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = Attribute.class))), + @ApiResponse(responseCode = "400", description = "Invalid input") + } + ) + ResponseEntity> createAttribute( + @Parameter(description = "Attribute to create", required = true) + @Valid @RequestBody Attribute attribute); + + /** + * Updates an existing Attribute entity. + * + * @param id the PID or internal ID of the Attribute to update + * @param attribute the updated Attribute entity + * @return the updated Attribute entity + */ + @PutMapping("/{id}") + @Operation( + summary = "Update an Attribute", + description = "Updates an existing Attribute entity", + responses = { + @ApiResponse(responseCode = "200", description = "Attribute updated", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = Attribute.class))), + @ApiResponse(responseCode = "400", description = "Invalid input"), + @ApiResponse(responseCode = "404", description = "Attribute not found") + } + ) + ResponseEntity> updateAttribute( + @Parameter(description = "PID or internal ID of the Attribute", required = true) + @PathVariable String id, + @Parameter(description = "Updated Attribute", required = true) + @Valid @RequestBody Attribute attribute); + + /** + * Deletes an Attribute entity. + * + * @param id the PID or internal ID of the Attribute to delete + * @return no content + */ + @DeleteMapping("/{id}") + @Operation( + summary = "Delete an Attribute", + description = "Deletes an Attribute entity", + responses = { + @ApiResponse(responseCode = "204", description = "Attribute deleted"), + @ApiResponse(responseCode = "404", description = "Attribute not found") + } + ) + ResponseEntity deleteAttribute( + @Parameter(description = "PID or internal ID of the Attribute", required = true) + @PathVariable String id); + + /** + * Deletes orphaned Attribute entities. + * An orphaned Attribute is one that has a dataType relationship but is not referenced by any other node. + * + * @return no content + */ + @DeleteMapping("/orphaned") + @Operation( + summary = "Delete orphaned Attributes", + description = "Deletes Attribute entities that are not referenced by any other node", + responses = { + @ApiResponse(responseCode = "204", description = "Orphaned Attributes deleted") + } + ) + ResponseEntity deleteOrphanedAttributes(); + + /** + * Partially updates an Attribute entity. + * + * @param id the PID or internal ID of the Attribute to patch + * @param attributePatch the partial Attribute entity with fields to update + * @return the patched Attribute entity + */ + @PatchMapping("/{id}") + @Operation( + summary = "Partially update an Attribute", + description = "Updates specific fields of an existing Attribute entity", + responses = { + @ApiResponse(responseCode = "200", description = "Attribute patched", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = Attribute.class))), + @ApiResponse(responseCode = "400", description = "Invalid input"), + @ApiResponse(responseCode = "404", description = "Attribute not found") + } + ) + ResponseEntity> patchAttribute( + @Parameter(description = "PID or internal ID of the Attribute", required = true) + @PathVariable String id, + @Parameter(description = "Partial Attribute with fields to update", required = true) + @RequestBody Attribute attributePatch); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/api/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/api/package-info.java new file mode 100644 index 0000000..07b3feb --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/api/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("attributes.web.api") +package edu.kit.datamanager.idoris.attributes.web.api; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/hateoas/AttributeModelAssembler.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/hateoas/AttributeModelAssembler.java new file mode 100644 index 0000000..bbf8024 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/hateoas/AttributeModelAssembler.java @@ -0,0 +1,65 @@ +/* + * 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.idoris.attributes.web.hateoas; + +import edu.kit.datamanager.idoris.attributes.dto.AttributeDto; +import edu.kit.datamanager.idoris.attributes.web.v1.AttributeController; +import io.micrometer.observation.annotation.Observed; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +/** + * Assembler that adds HATEOAS links to AttributeDto responses. + * Adds: + * - self: /v1/attributes/{id} + * - relations: dataType set/detach, override set/detach + */ +@Component +@Observed(contextualName = "attributeDtoModelAssembler") +public class AttributeModelAssembler implements RepresentationModelAssembler> { + + @Override + public EntityModel toModel(AttributeDto entity) { + EntityModel model = EntityModel.of(entity); + + // Add collection link + model.add(linkTo(methodOn(AttributeController.class).list()).withRel("collection")); + + // Relation operation links (templated with {id}) for discoverability + // Clients can replace {id} with their known identifier (PID or internalId) + String base = "/v1/attributes/{id}"; + model.add(Link.of(base).withSelfRel().withTitle("self (templated)")); + model.add(Link.of(base + "/dataType").withRel("dataType:set")); + model.add(Link.of(base + "/dataType").withRel("dataType:detach").withTitle("DELETE")); + model.add(Link.of(base + "/override").withRel("override:set")); + model.add(Link.of(base + "/override").withRel("override:detach").withTitle("DELETE")); + + return model; + } + + @Override + public CollectionModel> toCollectionModel(Iterable entities) { + CollectionModel> collection = RepresentationModelAssembler.super.toCollectionModel(entities); + collection.add(linkTo(methodOn(AttributeController.class).list()).withSelfRel()); + return collection; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/v1/AttributeController.java b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/v1/AttributeController.java new file mode 100644 index 0000000..43eaf3b --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/attributes/web/v1/AttributeController.java @@ -0,0 +1,159 @@ +/* + * 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.idoris.attributes.web.v1; + +import edu.kit.datamanager.idoris.attributes.api.IAttributeExternalService; +import edu.kit.datamanager.idoris.attributes.dto.AttributeDto; +import edu.kit.datamanager.idoris.attributes.web.hateoas.AttributeModelAssembler; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; + +/** + * DTO-first REST controller for Attributes. + * Provides endpoints for managing Attributes using DTOs exclusively. + */ +@RestController +@RequestMapping("/v1/attributes") +@Slf4j +@Observed(contextualName = "attributeController") +public class AttributeController { + + @Autowired + private IAttributeExternalService attributeService; + + @Autowired + private AttributeModelAssembler assembler; + + @GetMapping + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "attributeController.list", description = "Time taken to list attributes", histogram = true) + @Counted(value = "attributeController.list.count", description = "Number of attribute list requests") + public ResponseEntity>> list() { + List list = attributeService.list(); + return ResponseEntity.ok(assembler.toCollectionModel(list)); + } + + @GetMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "attributeController.get", description = "Time taken to get attribute", histogram = true) + @Counted(value = "attributeController.get.count", description = "Number of attribute get requests") + public ResponseEntity> get(@SpanAttribute("attribute.id") @PathVariable String id) { + Optional dto = attributeService.get(id); + return dto.map(d -> ResponseEntity.ok(assembler.toModel(d))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "attributeController.create", description = "Time taken to create attribute", histogram = true) + @Counted(value = "attributeController.create.count", description = "Number of attribute create requests") + public ResponseEntity> create(@RequestBody AttributeDto dto) { + AttributeDto created = attributeService.create(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(assembler.toModel(created)); + } + + @PutMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "attributeController.update", description = "Time taken to update attribute", histogram = true) + @Counted(value = "attributeController.update.count", description = "Number of attribute update requests") + public ResponseEntity> update(@SpanAttribute("attribute.id") @PathVariable String id, @RequestBody AttributeDto dto) { + if (attributeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + AttributeDto updated = attributeService.update(id, dto); + return ResponseEntity.ok(assembler.toModel(updated)); + } + + @PatchMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "attributeController.patch", description = "Time taken to patch attribute", histogram = true) + @Counted(value = "attributeController.patch.count", description = "Number of attribute patch requests") + public ResponseEntity> patch(@SpanAttribute("attribute.id") @PathVariable String id, @RequestBody AttributeDto dto) { + if (attributeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + AttributeDto patched = attributeService.patch(id, dto); + return ResponseEntity.ok(assembler.toModel(patched)); + } + + @DeleteMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "attributeController.delete", description = "Time taken to delete attribute", histogram = true) + @Counted(value = "attributeController.delete.count", description = "Number of attribute delete requests") + public ResponseEntity delete(@SpanAttribute("attribute.id") @PathVariable String id) { + if (attributeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + attributeService.delete(id); + return ResponseEntity.noContent().build(); + } + + // Relationship endpoints + + @PostMapping("/{id}/dataType") + @WithSpan(kind = SpanKind.SERVER) + public ResponseEntity> setDataType(@PathVariable String id, @RequestBody String dataTypeId) { + if (attributeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + AttributeDto dto = attributeService.setDataType(id, dataTypeId); + return ResponseEntity.ok(assembler.toModel(dto)); + } + + @DeleteMapping("/{id}/dataType") + @WithSpan(kind = SpanKind.SERVER) + public ResponseEntity> detachDataType(@PathVariable String id) { + if (attributeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + AttributeDto dto = attributeService.detachDataType(id); + return ResponseEntity.ok(assembler.toModel(dto)); + } + + @PostMapping("/{id}/override") + @WithSpan(kind = SpanKind.SERVER) + public ResponseEntity> setOverride(@PathVariable String id, @RequestBody String overrideAttributeId) { + if (attributeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + AttributeDto dto = attributeService.setOverride(id, overrideAttributeId); + return ResponseEntity.ok(assembler.toModel(dto)); + } + + @DeleteMapping("/{id}/override") + @WithSpan(kind = SpanKind.SERVER) + public ResponseEntity> detachOverride(@PathVariable String id) { + if (attributeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + AttributeDto dto = attributeService.detachOverride(id); + return ResponseEntity.ok(assembler.toModel(dto)); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/AdministrativeMetadataDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/AdministrativeMetadataDto.java new file mode 100644 index 0000000..b5d740a --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/AdministrativeMetadataDto.java @@ -0,0 +1,41 @@ +/* + * 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.idoris.core; + +import java.time.Instant; + +/** + * Marker interface for Response DTOs that expose administrative metadata maintained by the server. + * Records representing response payloads should implement this interface to provide a common + * contract across modules without leaking entity classes. + */ +public interface AdministrativeMetadataDto { + String internalId(); + + Long version(); + + Instant createdAt(); + + Instant lastModifiedAt(); + + default String createdBy() { + return null; + } + + default String lastModifiedBy() { + return null; + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/idoris/configuration/ApplicationProperties.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ApplicationProperties.java similarity index 68% rename from src/main/java/edu/kit/datamanager/idoris/configuration/ApplicationProperties.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ApplicationProperties.java index 521d66c..ab77408 100644 --- a/src/main/java/edu/kit/datamanager/idoris/configuration/ApplicationProperties.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ApplicationProperties.java @@ -13,12 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package edu.kit.datamanager.idoris.configuration; +package edu.kit.datamanager.idoris.core.configuration; import edu.kit.datamanager.idoris.rules.logic.OutputMessage; import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; import lombok.Getter; -import org.springframework.beans.factory.annotation.Value; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.validation.annotation.Validated; @@ -30,25 +33,20 @@ * * @author maximiliani */ -@Configuration @Getter +@Setter +@ConfigurationProperties(prefix = "idoris") +@Configuration +@AllArgsConstructor +@RequiredArgsConstructor @Validated public class ApplicationProperties { - /** - * The base URL of the IDORIS service, used in e.g., the PID records. - */ - @Value("${idoris.base-url") - @NotNull(message = "Base URL is required") - private String baseUrl; - /** * The policy to use for validating the input. * * @see ValidationPolicy */ - @Value("${idoris.validation-policy:LAX}") - @NotNull private ValidationPolicy validationPolicy = ValidationPolicy.LAX; /** @@ -56,25 +54,22 @@ public class ApplicationProperties { * * @see OutputMessage.MessageSeverity */ - @Value("${idoris.validation-level:INFO}") - @NotNull private OutputMessage.MessageSeverity validationLevel = INFO; /** - * The PID generation strategy to use. - *
  • - * LOCAL: Use the local PID generation strategy. - * This is the default strategy and uses the local database to generate PIDs. - *
  • - * TYPED_PID_MAKER: Use the Typed PID Maker service to generate PIDs. - * This strategy uses an external service to generate PIDs and therefore requires additional configuration. - * - * @see PIDGeneration - * @see TypedPIDMakerConfig + * The base URL of the application. + * This is used to generate links in the responses. + * It must be set to the public URL of the application. + * Example: https://idoris.example.com */ - @Value("${idoris.pid-generation}") - @NotNull - private PIDGeneration pidGeneration = PIDGeneration.LOCAL; + @NotNull(message = "Base URL is required") + private String baseUrl; + +// // Reads Keycloak related settings from application.properties +// @Bean +// public KeycloakJwtProperties properties() { +// return new KeycloakJwtProperties(); +// } /** * The policy to use for validating the input. @@ -86,9 +81,4 @@ public class ApplicationProperties { public enum ValidationPolicy { STRICT, LAX } - - public enum PIDGeneration { - LOCAL, - TYPED_PID_MAKER, - } } diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ETagConfiguration.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ETagConfiguration.java new file mode 100644 index 0000000..8e33a18 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ETagConfiguration.java @@ -0,0 +1,134 @@ +/* + * 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.idoris.core.configuration; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.MethodParameter; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +import java.io.IOException; +import java.util.Set; + +/** + * Configuration for ETag support. + * This configuration adds an ETag filter to the application that will generate ETags for responses + * and validate If-Match headers for non-idempotent operations (PUT, PATCH, DELETE). + */ +@Configuration +public class ETagConfiguration { + + private static final Set NON_IDEMPOTENT_METHODS = Set.of( + HttpMethod.PUT.name(), + HttpMethod.PATCH.name(), + HttpMethod.DELETE.name() + ); + + /** + * Creates an ETag filter bean. + * + * @param handlerExceptionResolver the handler exception resolver + * @return the ETag filter + */ + @Bean + public OncePerRequestFilter etagFilter(HandlerExceptionResolver handlerExceptionResolver) { + return new OncePerRequestFilter() { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // Check if the request is for a non-idempotent operation + if (NON_IDEMPOTENT_METHODS.contains(request.getMethod())) { + // Check if the If-Match header is present + String ifMatch = request.getHeader(HttpHeaders.IF_MATCH); + if (ifMatch == null || ifMatch.isEmpty()) { + response.setStatus(HttpStatus.PRECONDITION_REQUIRED.value()); + response.getWriter().write("If-Match header is required for non-idempotent operations"); + return; + } + } + + // Continue with the filter chain + filterChain.doFilter(request, response); + } + }; + } + + @ControllerAdvice + public class ETagControllerAdvice implements ResponseBodyAdvice { + + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + // Support all response types + return true; + } + + @Override + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, ServerHttpResponse response) { + + // Extract the entity from the response body + Object entity = body; + if (body instanceof EntityModel) { + entity = ((EntityModel) body).getContent(); + } + + // If the entity is an AdministrativeMetadata, add an ETag header + if (entity instanceof AdministrativeMetadata metadata) { + String etag = "\"" + metadata.getVersion() + "\""; + response.getHeaders().set(HttpHeaders.ETAG, etag); + + // Store the entity in the request attributes for the ETag filter + if (request instanceof ServletServerHttpRequest && response instanceof ServletServerHttpResponse) { + HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); + servletRequest.setAttribute("entity", metadata); + } + + // Check if this is a conditional request (If-Match header is present) + String ifMatch = request.getHeaders().getFirst(HttpHeaders.IF_MATCH); + if (ifMatch != null && !ifMatch.isEmpty()) { + // If the ETag doesn't match, return 412 Precondition Failed + if (!ifMatch.equals(etag) && !ifMatch.equals("*")) { + response.setStatusCode(HttpStatus.PRECONDITION_FAILED); + return null; + } + } + } + + return body; + } + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ElasticConfiguration.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ElasticConfiguration.java new file mode 100644 index 0000000..2bd6bbb --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ElasticConfiguration.java @@ -0,0 +1,43 @@ +/* + * 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.idoris.core.configuration; + +/* + * https://docs.spring.io/spring-boot/docs/3.0.x/reference/html/data.html#data.nosql.elasticsearch.connecting-using-rest.javaapiclient + * https://docs.spring.io/spring-data/elasticsearch/docs/current/reference/html/#elasticsearch.clients.restclient + */ +//@Configuration +//@ConditionalOnProperty(prefix = "repo.search", name = "enabled", havingValue = "true", matchIfMissing = false) +//public class ElasticConfiguration extends ElasticsearchConfiguration { +// +// private final SearchConfiguration searchConfiguration; +// +// public ElasticConfiguration(@Autowired SearchConfiguration searchConfiguration) { +// this.searchConfiguration = searchConfiguration; +// } +// +// @Override +// public ClientConfiguration clientConfiguration() { +// String serverUrl = searchConfiguration.getUrl().toString(); +// serverUrl = serverUrl.replace("http://", ""); +// serverUrl = serverUrl.replace("https://", ""); +// +// return ClientConfiguration.builder() +// .connectedTo(serverUrl) +// .build(); +// } +//} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/JacksonConfiguration.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/JacksonConfiguration.java new file mode 100644 index 0000000..ec8e56c --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/JacksonConfiguration.java @@ -0,0 +1,44 @@ +/* + * 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.idoris.core.configuration; + +import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static tools.jackson.databind.SerializationFeature.INDENT_OUTPUT; +import static tools.jackson.databind.cfg.DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS; + +@Configuration +public class JacksonConfiguration { + @Bean + JsonMapperBuilderCustomizer jacksonCustomizer() { + return builder -> builder + .disable(WRITE_DATES_AS_TIMESTAMPS) + .enable(INDENT_OUTPUT); + } + +// @Bean +// @Primary +// public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) { +// ObjectMapper mapper = builder.build(); +// mapper.registerModule(new JavaTimeModule()); +//// mapper.registerModule(new ValueObjectJacksonModule()); +// mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); +// return mapper; +// } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/Neo4JConfiguration.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/Neo4JConfiguration.java new file mode 100644 index 0000000..30ce5aa --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/Neo4JConfiguration.java @@ -0,0 +1,165 @@ +/* + * 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.idoris.core.configuration; + +import edu.kit.datamanager.idoris.core.domain.valueObjects.EmailAddress; +import edu.kit.datamanager.idoris.core.domain.valueObjects.Name; +import edu.kit.datamanager.idoris.core.domain.valueObjects.ORCiD; +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; +import org.neo4j.driver.Value; +import org.neo4j.driver.internal.value.StringValue; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.neo4j.core.convert.Neo4jConversions; + +import java.net.URL; +import java.util.HashSet; +import java.util.Set; + +@Configuration +//@EnableNeo4jRepositories +//@EnableNeo4jAuditing +//@EnableTransactionManagement +public class Neo4JConfiguration { +// @Bean +// org.neo4j.cypherdsl.core.renderer.Configuration cypherDslConfiguration() { +// return org.neo4j.cypherdsl.core.renderer.Configuration +// .newConfig() +// .withDialect(Dialect.NEO4J_5) +// .build(); +// } + + @Bean + public Neo4jConversions neo4jConversions() { + return new Neo4jConversions(Set.of( + new PIDConverter(), + new ORCIDConverter(), + new NameConverter(), + new EmailConverter() + )); + } +// +// @Bean({"neo4jTemplate"}) +// @ConditionalOnMissingBean({Neo4jOperations.class}) +// public Neo4jTemplate neo4jTemplate( +// Neo4jClient neo4jClient, +// Neo4jMappingContext neo4jMappingContext, +// Driver driver, DatabaseSelectionProvider databaseNameProvider, ObjectProvider optionalCustomizers +// ) { +// Neo4jTransactionManager transactionManager = new Neo4jTransactionManager(driver, databaseNameProvider); +// optionalCustomizers.ifAvailable((customizer) -> { +// customizer.customize(transactionManager); +// }); +// return new Neo4jTemplate(neo4jClient, neo4jMappingContext, transactionManager); +// } + + // Converter to handle conversion between PID and String + public static class PIDConverter implements GenericConverter { + + @Override + public Set getConvertibleTypes() { + Set convertiblePairs = new HashSet<>(); + convertiblePairs.add(new ConvertiblePair(PID.class, Value.class)); + convertiblePairs.add(new ConvertiblePair(Value.class, PID.class)); + return convertiblePairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (PID.class.isAssignableFrom(sourceType.getType())) { + // convert to Neo4j Driver Value + return new StringValue(source.toString()); + } else { + Value value = (Value) source; + // convert to MyCustomType + return new PID(value.asString()); + } + } + + } + + public static class ORCIDConverter implements GenericConverter { + + @Override + public Set getConvertibleTypes() { + Set convertiblePairs = new HashSet<>(); + convertiblePairs.add(new ConvertiblePair(ORCiD.class, URL.class)); + convertiblePairs.add(new ConvertiblePair(URL.class, ORCiD.class)); + return convertiblePairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (ORCiD.class.isAssignableFrom(sourceType.getType())) { + // convert to Neo4j Driver Value + return new StringValue(source.toString()); + } else { + // convert to MyCustomType + return new ORCiD((String) source); + } + } + + } + + public static class NameConverter implements GenericConverter { + + @Override + public Set getConvertibleTypes() { + Set convertiblePairs = new HashSet<>(); + convertiblePairs.add(new ConvertiblePair(Name.class, Value.class)); + convertiblePairs.add(new ConvertiblePair(Value.class, Name.class)); + return convertiblePairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (Name.class.isAssignableFrom(sourceType.getType())) { + // convert to Neo4j Driver Value + return new StringValue(source.toString()); + } else { + // convert to MyCustomType + Value value = (Value) source; + return new Name(value.asString()); + } + } + } + + public static class EmailConverter implements GenericConverter { + + @Override + public Set getConvertibleTypes() { + Set convertiblePairs = new HashSet<>(); + convertiblePairs.add(new ConvertiblePair(EmailAddress.class, Value.class)); + convertiblePairs.add(new ConvertiblePair(Value.class, EmailAddress.class)); + return convertiblePairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (EmailAddress.class.isAssignableFrom(sourceType.getType())) { + // convert to Neo4j Driver Value + return new StringValue(source.toString()); + } else { + Value value = (Value) source; + // convert to MyCustomType + return new EmailAddress(value.asString()); + } + } + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/OpenAPIConfig.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/OpenAPIConfig.java new file mode 100644 index 0000000..303155d --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/OpenAPIConfig.java @@ -0,0 +1,56 @@ +/* + * 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.idoris.core.configuration; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.models.ExternalDocumentation; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "IDORIS API", + version = "0.2.0", + description = "API for the Integrated Data Type and Operations Registry with Inheritance System (IDORIS). This API provides endpoints for managing data types, operations, and their relationships within IDORIS.", + contact = @io.swagger.v3.oas.annotations.info.Contact( + name = "KIT Data Manager Team", + email = "webmaster@datamanager.kit.edu", + url = "https://kit-data-manager.github.io/webpage" + ) + ) +) +public class OpenAPIConfig { + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new io.swagger.v3.oas.models.info.Info() + .title("IDORIS API") + .version("0.2.0") + .description("API documentation for IDORIS system") + .contact(new Contact() + .name("KIT Data Manager Team") + .email("webmaster@datamanager.kit.edu") + .url("https://kit-data-manager.github.io/webpage"))) + .externalDocs(new ExternalDocumentation() + .description("IDORIS GitHub Repository") + .url("https://github.com/maximiliani/idoris")); + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/PIISpanAttribute.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/PIISpanAttribute.java new file mode 100644 index 0000000..2d27a1d --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/PIISpanAttribute.java @@ -0,0 +1,48 @@ +/* + * 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.idoris.core.configuration; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to mark method parameters that contain PII (Personally Identifiable Information) + * for conditional inclusion in OpenTelemetry spans. + *

    + * This annotation works like @SpanAttribute but only processes the parameter + * when pit.observability.includePiiInTraces=true is configured. + *

    + * Usage: + *

    + * public void myMethod(@PIISpanAttribute("pid") String pidValue,
    + *                      @PIISpanAttribute PIDRecord record) {
    + *     // method implementation
    + * }
    + * 
    + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface PIISpanAttribute { + + /** + * The name of the span attribute. If not provided, the parameter name will be used. + * + * @return the span attribute name + */ + String value() default ""; +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/PIISpanAttributeAspect.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/PIISpanAttributeAspect.java new file mode 100644 index 0000000..529e923 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/PIISpanAttributeAspect.java @@ -0,0 +1,352 @@ +/* + * 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.idoris.core.configuration; + +import io.opentelemetry.api.trace.Span; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +/** + * Aspect-Oriented Programming (AOP) aspect that automatically intercepts method calls + * within the PIT service to extract and add Personally Identifiable Information (PII) + * data to OpenTelemetry distributed tracing spans. + * + *

    This aspect provides enhanced observability for development and debugging purposes + * by capturing sensitive parameter values that are explicitly marked with the + * {@link PIISpanAttribute} annotation.

    + * + *

    Key Features:

    + *
      + *
    • Conditional Activation: Only created when the configuration property + * {@code pit.observability.includePiiInTraces=true} is set
    • + *
    • Broad Interception: Intercepts ALL methods in the + * {@code edu.kit.datamanager.pit} package tree
    • + *
    • Selective Processing: Only processes methods that have parameters + * annotated with {@link PIISpanAttribute}
    • + *
    • OpenTelemetry Integration: Seamlessly integrates with the existing + * tracing infrastructure
    • + *
    + * + *

    Security and Privacy Considerations:

    + *

    ⚠️ CRITICAL SECURITY WARNING: This aspect captures and exports + * potentially sensitive PII data to tracing systems. This functionality should + * NEVER be enabled in production environments and should be used + * with extreme caution in development environments.

    + * + *
      + *
    • PII data may include user IDs, email addresses, personal identifiers, etc.
    • + *
    • Traced data may be stored in external observability platforms
    • + *
    • Ensure compliance with privacy regulations (GDPR, CCPA, etc.)
    • + *
    • Consider data retention policies and access controls
    • + *
    + * + *

    Performance Considerations:

    + *
      + *
    • Intercepts ALL method calls in the pit package (performance overhead)
    • + *
    • Uses reflection to inspect method parameters (additional CPU cost)
    • + *
    • Should be disabled in performance-critical production environments
    • + *
    + * + *

    Usage Example:

    + *
    + * {@code
    + * @Service
    + * public class UserService {
    + *     public User findUser(@PIISpanAttribute("userId") String userId) {
    + *         // This method call will be intercepted and userId will be added to the span
    + *         return userRepository.findById(userId);
    + *     }
    + * }
    + * }
    + * 
    + * + * @see PIISpanAttribute + * @see Span + */ +@Aspect +@Component +@ConditionalOnProperty(name = "pit.observability.includePiiInTraces", havingValue = "true") +public class PIISpanAttributeAspect { + + private static final Logger LOG = LoggerFactory.getLogger(PIISpanAttributeAspect.class); + + /** + * Spring's parameter name discoverer used to retrieve parameter names from method signatures. + * This is essential when the {@link PIISpanAttribute} annotation doesn't specify a custom + * attribute name - we fall back to using the actual parameter name from the source code. + * + *

    The DefaultParameterNameDiscoverer tries multiple strategies: + *

      + *
    • Uses debug information if available (compiled with -g flag)
    • + *
    • Falls back to ASM-based bytecode analysis
    • + *
    • Uses Java 8+ parameter names if compiled with -parameters flag
    • + *
    + */ + private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + /** + * Constructor that logs the activation of PII tracing with appropriate warnings. + * + *

    This constructor is only called when the Spring condition + * {@code pit.observability.includePiiInTraces=true} is met, ensuring that + * the aspect is only active when explicitly configured.

    + * + *

    The constructor logs both informational and warning messages to ensure + * that the activation of PII tracing is clearly visible in application logs, + * helping to prevent accidental deployment to production environments.

    + */ + public PIISpanAttributeAspect() { + LOG.info("PIISpanAttributeAspect created - PII data will be included in traces"); + LOG.warn("WARNING: PII tracing is enabled! This should only be used in development environments."); + LOG.info("Aspect will intercept methods in package: edu.kit.datamanager.pit.*"); + LOG.info("Only methods with @PIISpanAttribute annotated parameters will have PII data extracted"); + } + + /** + * AspectJ around advice that intercepts ALL method calls within the PIT service + * package hierarchy to identify and process methods containing PII parameters. + * + *

    This method uses a broad pointcut expression that matches every method execution + * in the {@code edu.kit.datamanager.pit} package and all its sub-packages. While this + * approach has performance implications, it ensures comprehensive coverage without + * requiring developers to explicitly mark classes or methods.

    + * + *

    Execution Flow:

    + *
      + *
    1. Intercept method call before execution
    2. + *
    3. Perform quick scan of method parameters for {@link PIISpanAttribute} annotations
    4. + *
    5. If PII parameters found, extract and add them to the current OpenTelemetry span
    6. + *
    7. Proceed with original method execution
    8. + *
    9. Handle any errors gracefully without disrupting the original method
    10. + *
    + * + *

    Performance Optimization:

    + *

    To minimize performance impact, this method performs a quick preliminary check + * for PII annotations before proceeding with the more expensive span processing. + * Methods without PII parameters are processed with minimal overhead.

    + * + *

    Error Handling:

    + *

    Any exceptions during PII processing are caught and logged but do not interfere + * with the original method execution. This ensures that tracing issues don't break + * application functionality.

    + * + * @param joinPoint the AspectJ join point containing method signature and arguments + * @return the result of the original method execution + * @throws Throwable any exception thrown by the original method (PII processing exceptions are caught) + */ + @Around("execution(* edu.kit.datamanager.pit..*(..))") + public Object interceptAllMethods(ProceedingJoinPoint joinPoint) throws Throwable { + // Extract method information using AspectJ reflection capabilities + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Parameter[] parameters = method.getParameters(); + + // Performance optimization: Quick scan for PII annotations before expensive processing + // This avoids the overhead of span processing for methods that don't have PII data + boolean hasPIIParams = false; + for (Parameter parameter : parameters) { + if (parameter.getAnnotation(PIISpanAttribute.class) != null) { + hasPIIParams = true; + break; // Early exit once we find the first PII parameter + } + } + + // Only process methods that actually have PII parameters + if (hasPIIParams) { + LOG.info("Found method with PII parameters: {}.{}", + method.getDeclaringClass().getSimpleName(), method.getName()); + + try { + LOG.info("Processing PII parameters for tracing"); + // Delegate to specialized method for span attribute processing + addPIIAttributesToCurrentSpan(joinPoint); + } catch (Exception e) { + // Critical: Ensure that PII processing errors don't break the application + // Log the error but continue with normal method execution + LOG.warn("Failed to add PII span attributes: {}", e.getMessage(), e); + } + } + + // Always proceed with the original method execution + // This is the core of the around advice - we must call proceed() to execute the original method + return joinPoint.proceed(); + } + + /** + * Core processing method that extracts PII data from method parameters and adds + * them as attributes to the current OpenTelemetry span. + * + *

    This method performs the detailed work of: + *

      + *
    • Validating that a valid OpenTelemetry span context exists
    • + *
    • Iterating through method parameters to find PII annotations
    • + *
    • Extracting parameter values and converting them to string representations
    • + *
    • Adding the PII data as span attributes for observability
    • + *
    + * + *

    OpenTelemetry Integration:

    + *

    This method relies on OpenTelemetry's automatic span propagation through + * thread-local storage. The {@code Span.current()} call retrieves the active + * span from the current thread's context, which should have been created by + * OpenTelemetry's auto-instrumentation or manual span creation.

    + * + *

    Parameter Name Resolution:

    + *

    The method uses Spring's {@link ParameterNameDiscoverer} to resolve parameter + * names when the annotation doesn't specify a custom attribute name. This requires + * that the application be compiled with parameter name information (Java 8+ with + * -parameters flag or debug information with -g flag).

    + * + *

    Data Safety:

    + *
      + *
    • Null parameter values are safely handled and logged
    • + *
    • Very long parameter values are truncated in log messages (but not in spans)
    • + *
    • All parameter values are converted to strings using {@code toString()}
    • + *
    + * + * @param joinPoint the AspectJ join point containing method signature and runtime arguments + */ + private void addPIIAttributesToCurrentSpan(ProceedingJoinPoint joinPoint) { + // Extract all necessary method information from the join point + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Object[] args = joinPoint.getArgs(); // Runtime argument values + Parameter[] parameters = method.getParameters(); // Method parameter definitions + + // Attempt to discover parameter names for cases where annotation doesn't specify attribute name + // This uses reflection and bytecode analysis to retrieve the original parameter names + String[] parameterNames = parameterNameDiscoverer.getParameterNames(method); + + // Retrieve the current OpenTelemetry span from thread-local context + // This span should have been created by OpenTelemetry's auto-instrumentation + Span currentSpan = Span.current(); + if (currentSpan == null) { + LOG.warn("No current span available for PII attributes - OpenTelemetry may not be properly configured"); + return; + } + + // Validate that the span context is properly initialized and active + // An invalid span context indicates tracing infrastructure issues + if (!currentSpan.getSpanContext().isValid()) { + LOG.warn("Current span context is not valid - span may have been closed or not properly created"); + return; + } + + LOG.info("Current span: {}", currentSpan.getSpanContext().getSpanId()); + + // Process each parameter to look for PII annotations + int piiParamsProcessed = 0; + for (int i = 0; i < parameters.length && i < args.length; i++) { + Parameter parameter = parameters[i]; + PIISpanAttribute piiAnnotation = parameter.getAnnotation(PIISpanAttribute.class); + + if (piiAnnotation != null) { + if (args[i] != null) { + // Determine the span attribute name (from annotation or parameter name) + String attributeName = determineAttributeName(piiAnnotation, parameterNames, i); + + // Convert parameter value to string representation + // Note: This uses toString() which may not be suitable for all object types + String attributeValue = args[i].toString(); + + // Add the PII data to the OpenTelemetry span as a custom attribute + // This data will be included in distributed traces and exported to observability platforms + currentSpan.setAttribute(attributeName, attributeValue); + piiParamsProcessed++; + + // Log the addition with truncation for very long values to avoid log spam + LOG.debug("Successfully added PII span attribute: {} = {}", attributeName, + attributeValue.length() > 100 ? attributeValue.substring(0, 100) + "..." : attributeValue); + } else { + // Handle null parameter values gracefully - log but don't add to span + LOG.debug("PII parameter at index {} is null, skipping", i); + } + } + } + + // Summary logging to track PII processing activity + LOG.info("Total PII parameters processed: {} for method {}.{}", piiParamsProcessed, + method.getDeclaringClass().getSimpleName(), method.getName()); + } + + /** + * Determines the most appropriate span attribute name for a PII parameter using + * a fallback strategy that prioritizes explicit annotation values, then parameter + * names, and finally generates a generic name. + * + *

    This method implements a three-tier naming strategy:

    + *
      + *
    1. Explicit Annotation Value: If the {@link PIISpanAttribute} + * annotation specifies a non-empty value, use it as the attribute name. + * This gives developers full control over span attribute naming.
    2. + *
    3. Parameter Name Discovery: If no explicit value is provided, + * attempt to use the actual parameter name from the method signature. + * This requires proper compilation settings to preserve parameter names.
    4. + *
    5. Generated Fallback: If parameter names are not available, + * generate a generic attribute name based on the parameter position.
    6. + *
    + * + *

    Compilation Requirements for Parameter Names:

    + *

    For the second tier to work properly, the application must be compiled with + * one of the following options:

    + *
      + *
    • Java 8+ with -parameters flag: Preserves parameter names in bytecode
    • + *
    • Debug information (-g flag): Includes variable names in debug info
    • + *
    • IDE default settings: Most IDEs enable parameter name preservation by default
    • + *
    + * + *

    Attribute Naming Best Practices:

    + *
      + *
    • Use descriptive, consistent names for span attributes
    • + *
    • Consider namespace prefixes for application-specific attributes (e.g., "app.user.id")
    • + *
    • Avoid special characters that may cause issues in observability platforms
    • + *
    • Keep names reasonably short to minimize storage overhead
    • + *
    + * + * @param annotation the PII span attribute annotation that may contain an explicit name + * @param parameterNames array of parameter names discovered from the method signature, may be null + * @param parameterIndex zero-based index of the parameter in the method signature + * @return the determined span attribute name, never null or empty + */ + private String determineAttributeName(PIISpanAttribute annotation, String[] parameterNames, int parameterIndex) { + // First priority: Use explicit annotation value if provided + // This allows developers to specify meaningful, consistent attribute names + if (!annotation.value().isEmpty()) { + return annotation.value(); + } + + // Second priority: Use discovered parameter name if available + // This provides reasonable default names that match the source code + if (parameterNames != null && parameterIndex < parameterNames.length) { + return parameterNames[parameterIndex]; + } + + // Final fallback: Generate a generic but unique attribute name + // This ensures we always have a valid attribute name, even when parameter + // names are not available due to compilation settings + return "pii_arg" + parameterIndex; + } +} diff --git a/src/main/java/edu/kit/datamanager/idoris/configuration/TypedPIDMakerConfig.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/TypedPIDMakerConfig.java similarity index 52% rename from src/main/java/edu/kit/datamanager/idoris/configuration/TypedPIDMakerConfig.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/TypedPIDMakerConfig.java index 8990149..532592b 100644 --- a/src/main/java/edu/kit/datamanager/idoris/configuration/TypedPIDMakerConfig.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/TypedPIDMakerConfig.java @@ -14,14 +14,11 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.configuration; +package edu.kit.datamanager.idoris.core.configuration; import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; -import org.springframework.boot.autoconfigure.AutoConfigureAfter; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import org.springframework.validation.annotation.Validated; @@ -29,37 +26,30 @@ @Component @ConfigurationProperties("idoris.typed-pid-maker") @Validated -@AutoConfigureAfter(value = ApplicationProperties.class) -@ConditionalOnBean(value = ApplicationProperties.class) -@ConditionalOnExpression( - "#{ '${idoris.pid-generation}' eq T(edu.kit.datamanager.idoris.configuration.ApplicationProperties.PIDGeneration).TYPED_PID_MAKER.name() }" -) @Getter @Setter public class TypedPIDMakerConfig { /** - * Put metadata of the GenericIDORISEntity into the PID record. - * - * @see edu.kit.datamanager.idoris.domain.GenericIDORISEntity + * The base URL for the Typed PID Maker logic. + * This is required when the logic is enabled. */ - private boolean meaningfulPIDRecords = true; + @NotNull(message = "Base URL is required when enabled is true") + private String baseUrl; /** - * Update existing PID records with the latest metadata from the GenericIDORISEntity. - * If set to false, existing PID records will not be updated, - * but new records will still be created with the latest metadata. + * When true, include AdministrativeMetadata fields in created PID records. + * -- GETTER -- + * Backward-compatible boolean getter expected by tests. */ - private boolean updatePIDRecords = true; + private boolean meaningfulPIDRecords = false; /** - * The base URL for the Typed PID Maker service. - * This is required when the service is enabled. + * When true, update existing PID records when a PID is already present on the entity. */ - @NotNull(message = "Base URL is required when enabled is true") - private String baseUrl; + private boolean updatePIDRecords = false; /** - * The timeout in milliseconds for requests to the Typed PID Maker service. + * The timeout in milliseconds for requests to the Typed PID Maker logic. */ private int timeout = 5000; } diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ValidationConfig.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ValidationConfig.java new file mode 100644 index 0000000..9177a99 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/ValidationConfig.java @@ -0,0 +1,119 @@ +/* + * 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.idoris.core.configuration; + +import edu.kit.datamanager.idoris.rules.logic.OutputMessage; +import edu.kit.datamanager.idoris.rules.logic.RuleService; +import edu.kit.datamanager.idoris.rules.logic.RuleTask; +import edu.kit.datamanager.idoris.rules.logic.VisitableElement; +import edu.kit.datamanager.idoris.rules.validation.ValidationResult; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +import java.util.List; + +/** + * Configuration class for validation-related settings. + * This class configures validators and validation-related beans. + */ +@Configuration +@Slf4j +public class ValidationConfig { + + /** + * Creates a validator that uses the rule-based validation system. + * + * @param ruleService the rule logic + * @param applicationProperties the application properties + * @return a validator that uses the rule-based validation system + */ + @Bean + public Validator ruleBasedValidator(RuleService ruleService, ApplicationProperties applicationProperties) { + return new RuleBasedVisitableElementValidator(ruleService, applicationProperties); + } + + /** + * Spring validator that uses the rule-based validation system. + * This validator integrates with Spring's validation framework and executes + * all applicable validation rules through the RuleService. + */ + private record RuleBasedVisitableElementValidator(RuleService ruleService, + ApplicationProperties applicationProperties) implements Validator { + + @Override + public boolean supports(@NonNull Class clazz) { + return VisitableElement.class.isAssignableFrom(clazz); + } + + @Override + public void validate(@NonNull Object target, @NonNull Errors errors) { + if (!(target instanceof VisitableElement element)) { + return; + } + + try { + // Execute validation rules using RuleService + ValidationResult result = ruleService.executeRules( + RuleTask.VALIDATE, + element, + ValidationResult::new + ); + + // Convert ValidationResult to Spring validation errors + convertToSpringErrors(result, errors); + + } catch (Exception e) { + log.error("Error during rule-based validation: " + e.getMessage()); + errors.reject("validation.error", "Validation failed due to internal error"); + } + } + + /** + * Converts ValidationResult messages to Spring validation errors. + * Only includes messages that meet the configured validation level threshold. + */ + private void convertToSpringErrors(ValidationResult result, Errors errors) { + if (result == null || result.isEmpty()) { + return; + } + + result.getOutputMessages().entrySet().stream() + .filter(entry -> entry.getKey().isHigherOrEqualTo(applicationProperties.getValidationLevel())) + .forEach(entry -> { + OutputMessage.MessageSeverity severity = entry.getKey(); + List messages = entry.getValue(); + + for (OutputMessage message : messages) { + String errorCode = "validation." + severity.name().toLowerCase(); + String defaultMessage = message.message(); + + if (severity == OutputMessage.MessageSeverity.ERROR) { + // Errors are always rejected, no matter the configuration + errors.reject(errorCode, defaultMessage); + } else { + // For warnings and info, we can still add them, but they won't fail validation + errors.reject("validation.warning", defaultMessage); + } + } + }); + } + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/WebConfig.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/WebConfig.java new file mode 100644 index 0000000..92c681a --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/configuration/WebConfig.java @@ -0,0 +1,140 @@ +/* + * 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.idoris.core.configuration; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.firewall.DefaultHttpFirewall; +import org.springframework.security.web.firewall.HttpFirewall; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Configuration class for web-related settings. + * This class configures CORS, HATEOAS, and other web-related settings. + */ +@Configuration +@EnableHypermediaSupport(type = {EnableHypermediaSupport.HypermediaType.HAL, EnableHypermediaSupport.HypermediaType.HAL_FORMS, EnableHypermediaSupport.HypermediaType.COLLECTION_JSON}) +@EnableWebSecurity +@EnableMethodSecurity +@Slf4j +public class WebConfig implements WebMvcConfigurer { + + // @Value("${idoris.security.enable-auth:false}") +// private boolean enableAuth; +// @Value("${idoris.security.enable-csrf:true}") +// private boolean enableCsrf; + @Value("${idoris.security.allowedOriginPattern:http*://localhost:[*]}") + private String allowedOriginPattern; + + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + configurer.setDefaultVersion("1"); + configurer.useRequestHeader("API-Version"); + } + + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addViewController("/").setViewName("forward:/swagger-ui.html"); + } + + @Bean + protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable).authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + +// http.authorizeHttpRequests(authorize -> authorize +// // everyone, even unauthenticated users may do HTTP OPTIONS on urls or access swagger +// .requestMatchers(HttpMethod.OPTIONS, "/**", "/swagger-ui.html", "/swagger-ui/*", "/v3/**").permitAll() +// // permit access to actuator endpoints +// .requestMatchers("/actuator/**").permitAll() +// // TODO protect the actual API +// .requestMatchers("/api/v1/**").permitAll()) +// // do not store sessions (use stateless "sessions") +// .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) +//// .addFilterAfter(keycloaktokenFilterBean(), BasicAuthenticationFilter.class) +// .headers(headers -> headers.cacheControl(HeadersConfigurer.CacheControlConfig::disable)) +// .csrf(csrf -> { +// if (!enableCsrf) { +// log.info("Disable CSRF"); +// // https://developer.mozilla.org/en-US/docs/Glossary/CSRF +// csrf.disable(); +// } +// }); +// +// +// if (!enableAuth) { +// log.info("Authentication is DISABLED. Adding 'NoAuthenticationFilter' to authentication chain."); +// AuthenticationManager defaultAuthenticationManager = http.getSharedObject(AuthenticationManager.class); +//// http.addFilterAfter(new NoAuthenticationFilter(jwtSecret, defaultAuthenticationManager), KeycloakTokenFilter.class); TODO +// } else { +// log.info("Authentication is ENABLED."); +// } +// +// return http.build(); + } + + /** + * Configures CORS settings. + * + * @return the WebMvcConfigurer with CORS configuration + */ + @Bean + public WebSecurityCustomizer webSecurity() { + return web -> web.httpFirewall(allowUrlEncodedSlashHttpFirewall()); + } + + @Bean + public HttpFirewall allowUrlEncodedSlashHttpFirewall() { + DefaultHttpFirewall firewall = new DefaultHttpFirewall(); + // might be necessary for certain identifier types. + firewall.setAllowUrlEncodedSlash(true); + return firewall; + } + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration corsConfig = new CorsConfiguration(); + corsConfig.setAllowCredentials(false); + corsConfig.addAllowedOriginPattern(allowedOriginPattern); + corsConfig.addAllowedHeader("*"); + corsConfig.addAllowedMethod("*"); + corsConfig.addExposedHeader("Content-Range"); + corsConfig.addExposedHeader("ETag"); + corsConfig.addExposedHeader("Last-Modified"); + corsConfig.addExposedHeader("Expires"); + corsConfig.addExposedHeader("Cache-Control"); + corsConfig.addExposedHeader("Trace-ID"); + + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfig); + return new CorsFilter(source); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/dao/IGenericRepo.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/dao/IGenericRepo.java new file mode 100644 index 0000000..1cbff70 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/dao/IGenericRepo.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024-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.idoris.core.dao; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import org.springframework.data.neo4j.repository.Neo4jRepository; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.data.repository.ListCrudRepository; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface IGenericRepo extends Repository, Neo4jRepository, ListCrudRepository, PagingAndSortingRepository { + /** + * Finds an entity by its ID, which can be either a PID or an internal ID. + * This method first tries to find the entity by PID, and if not found, tries to find it by internal ID. + * + * @param id The ID of the entity to find (either PID or internal ID) + * @return An Optional containing the entity if found, or empty if not found + */ + default Optional findByPIDorInternalId(String id) { + Optional byPid = findByPid(id); + return byPid.isPresent() ? byPid : findByInternalId(id); + } + + /** + * Finds an entity by its PID. + * This method queries the PIDNode table to find the entity associated with the given PID. + * + * @param pid The PID of the entity to find + * @return An Optional containing the entity if found, or empty if not found + */ + @Query("MATCH (p:PIDNode {pid: $pid})-[:IDENTIFIES]->(e:IDORIS) RETURN e") + Optional findByPid(@Param("pid") String pid); + + /** + * Finds an entity by its internal ID. + * This method queries the PIDNode table to find the entity with the given internal ID. + * + * @param internalId The internal ID of the entity to find + * @return An Optional containing the entity if found, or empty if not found + */ + Optional findByInternalId(@Param("internalId") String internalId); + + /** + * Finds all PIDs associated with a given internal ID. + * This method queries the PIDNode table to find all PIDs that identify the entity with the given internal ID. + * + * @param internalId The internal ID of the entity + * @return A list of PIDNode objects associated with the internal ID + */ + @Query("MATCH (p:PIDNode)-[:IDENTIFIES]->(e:IDORIS {internalId: $internalId}) RETURN p") + List findPidsByInternalId(@Param("internalId") String internalId); +} diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/GenericIDORISEntity.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/AdministrativeMetadata.java similarity index 64% rename from src/main/java/edu/kit/datamanager/idoris/domain/GenericIDORISEntity.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/AdministrativeMetadata.java index ec29207..ac08e47 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/GenericIDORISEntity.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/AdministrativeMetadata.java @@ -14,17 +14,20 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain; +package edu.kit.datamanager.idoris.core.domain; -import edu.kit.datamanager.idoris.domain.entities.Reference; -import edu.kit.datamanager.idoris.domain.entities.User; -import edu.kit.datamanager.idoris.pids.ConfigurablePIDGenerator; +import edu.kit.datamanager.idoris.core.domain.valueObjects.Name; +import edu.kit.datamanager.idoris.core.domain.valueObjects.Reference; +import edu.kit.datamanager.idoris.rules.logic.VisitableElement; import lombok.*; import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.annotation.Version; import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Node; import org.springframework.data.neo4j.core.schema.Relationship; +import org.springframework.data.neo4j.core.support.UUIDStringGenerator; import java.io.Serializable; import java.time.Instant; @@ -32,16 +35,18 @@ @Getter @Setter -@EqualsAndHashCode(callSuper = true) +@EqualsAndHashCode @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED) -public abstract class GenericIDORISEntity extends VisitableElement implements Serializable { - @GeneratedValue(ConfigurablePIDGenerator.class) - String pid; +@Node("IDORIS") +public abstract class AdministrativeMetadata implements Serializable, VisitableElement { + @Id + @GeneratedValue(UUIDStringGenerator.class) + String internalId; - String name; + Name name; - String description; + Name description; @Version Long version; @@ -52,10 +57,15 @@ public abstract class GenericIDORISEntity extends VisitableElement implements Se @LastModifiedDate Instant lastModifiedAt; - Set expectedUseCases; + Set expectedUseCases; @Relationship(value = "contributors", direction = Relationship.Direction.OUTGOING) Set contributors; Set references; -} \ No newline at end of file + + @Override + public String getId() { + return internalId; + } +} diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/AtomicDataType.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/AtomicDataType.java similarity index 87% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/AtomicDataType.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/AtomicDataType.java index 9a23a6a..a4cdc58 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/AtomicDataType.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/AtomicDataType.java @@ -14,9 +14,9 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain; -import edu.kit.datamanager.idoris.domain.enums.PrimitiveDataTypes; +import edu.kit.datamanager.idoris.core.domain.enums.PrimitiveDataTypes; import edu.kit.datamanager.idoris.rules.logic.RuleOutput; import edu.kit.datamanager.idoris.rules.logic.Visitor; import lombok.AllArgsConstructor; @@ -33,7 +33,7 @@ @Setter @AllArgsConstructor @NoArgsConstructor -public final class AtomicDataType extends DataType { +public class AtomicDataType extends DataType { @Relationship(value = "inheritsFrom", direction = Relationship.Direction.OUTGOING) private AtomicDataType inheritsFrom; @@ -46,7 +46,7 @@ public final class AtomicDataType extends DataType { private Integer maximum; @Override - protected > T accept(Visitor visitor, Object... args) { + public > T accept(Visitor visitor, Object... args) { return visitor.visit(this, args); } @@ -60,5 +60,4 @@ public boolean inheritsFrom(DataType dataType) { } return false; } - } diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/Attribute.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/Attribute.java similarity index 79% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/Attribute.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/Attribute.java index 1f23fe2..3f008f9 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/Attribute.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/Attribute.java @@ -14,9 +14,8 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain; -import edu.kit.datamanager.idoris.domain.GenericIDORISEntity; import edu.kit.datamanager.idoris.rules.logic.RuleOutput; import edu.kit.datamanager.idoris.rules.logic.Visitor; import jakarta.validation.constraints.NotNull; @@ -32,21 +31,21 @@ @RequiredArgsConstructor @Getter @Setter -public class Attribute extends GenericIDORISEntity { +public class Attribute extends AdministrativeMetadata { private String defaultValue; private String constantValue; private Integer lowerBoundCardinality = 0; private Integer upperBoundCardinality; - @Relationship(value = "dataType", direction = Relationship.Direction.OUTGOING) + // Keep only the target ID to avoid cross-module dependency. Relationship managed via DAO methods. @NotNull - private DataType dataType; + private String dataTypeId; @Relationship(value = "override", direction = Relationship.Direction.OUTGOING) private Attribute override; @Override - protected > T accept(Visitor visitor, Object... args) { + public > T accept(Visitor visitor, Object... args) { return visitor.visit(this, args); } -} +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/ORCiDUser.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/DataType.java similarity index 70% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/ORCiDUser.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/DataType.java index 44e58dc..6a95abe 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/ORCiDUser.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/DataType.java @@ -14,21 +14,21 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain; -import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Setter; import org.springframework.data.neo4j.core.schema.Node; -import java.net.URL; - +@Node("DataType") @Getter +@Setter @AllArgsConstructor -@NoArgsConstructor -@Node("ORCiDUser") -public final class ORCiDUser extends User { - @JsonProperty("orcid") - private URL orcid; +@RequiredArgsConstructor +public abstract class DataType extends AdministrativeMetadata { + private String defaultValue; + + public abstract boolean inheritsFrom(DataType dataType); } diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/Operation.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/Operation.java similarity index 86% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/Operation.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/Operation.java index 6afc8ec..4434250 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/Operation.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/Operation.java @@ -14,9 +14,9 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain; -import edu.kit.datamanager.idoris.domain.GenericIDORISEntity; +import edu.kit.datamanager.idoris.core.domain.valueObjects.OperationStep; import edu.kit.datamanager.idoris.rules.logic.RuleOutput; import edu.kit.datamanager.idoris.rules.logic.Visitor; import lombok.AllArgsConstructor; @@ -34,7 +34,7 @@ @Setter @AllArgsConstructor @RequiredArgsConstructor -public class Operation extends GenericIDORISEntity { +public class Operation extends AdministrativeMetadata { @Relationship(value = "executableOn", direction = Relationship.Direction.OUTGOING) private Attribute executableOn; @Relationship(value = "returns", direction = Relationship.Direction.INCOMING) @@ -46,7 +46,7 @@ public class Operation extends GenericIDORISEntity { private List execution; @Override - protected > T accept(Visitor visitor, Object... args) { + public > T accept(Visitor visitor, Object... args) { return visitor.visit(this, args); } } diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/TechnologyInterface.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/TechnologyInterface.java similarity index 84% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/TechnologyInterface.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/TechnologyInterface.java index e311354..caae7d5 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/TechnologyInterface.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/TechnologyInterface.java @@ -14,9 +14,8 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain; -import edu.kit.datamanager.idoris.domain.GenericIDORISEntity; import edu.kit.datamanager.idoris.rules.logic.RuleOutput; import edu.kit.datamanager.idoris.rules.logic.Visitor; import lombok.AllArgsConstructor; @@ -33,7 +32,7 @@ @Setter @RequiredArgsConstructor @AllArgsConstructor -public class TechnologyInterface extends GenericIDORISEntity { +public class TechnologyInterface extends AdministrativeMetadata { @Relationship(value = "attributes", direction = Relationship.Direction.INCOMING) private Set attributes; @@ -44,7 +43,7 @@ public class TechnologyInterface extends GenericIDORISEntity { private Set adapters = Set.of(); @Override - protected > T accept(Visitor visitor, Object... args) { + public > T accept(Visitor visitor, Object... args) { return visitor.visit(this, args); } -} +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/TypeProfile.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/TypeProfile.java similarity index 88% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/TypeProfile.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/TypeProfile.java index f2870f2..f4ef4da 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/TypeProfile.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/TypeProfile.java @@ -14,9 +14,9 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain; -import edu.kit.datamanager.idoris.domain.enums.CombinationOptions; +import edu.kit.datamanager.idoris.core.domain.enums.CombinationOptions; import edu.kit.datamanager.idoris.rules.logic.RuleOutput; import edu.kit.datamanager.idoris.rules.logic.Visitor; import lombok.AllArgsConstructor; @@ -33,7 +33,7 @@ @Setter @AllArgsConstructor @NoArgsConstructor -public final class TypeProfile extends DataType { +public class TypeProfile extends DataType { @Relationship(value = "inheritsFrom", direction = Relationship.Direction.OUTGOING) private Set inheritsFrom; @@ -47,7 +47,7 @@ public final class TypeProfile extends DataType { private CombinationOptions validationPolicy = CombinationOptions.ALL; @Override - protected > T accept(Visitor visitor, Object... args) { + public > T accept(Visitor visitor, Object... args) { return visitor.visit(this, args); } diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/User.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/User.java similarity index 72% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/User.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/User.java index a3c69ea..a50bb57 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/User.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/User.java @@ -14,16 +14,18 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; +import edu.kit.datamanager.idoris.core.domain.valueObjects.EmailAddress; +import edu.kit.datamanager.idoris.core.domain.valueObjects.Name; +import edu.kit.datamanager.idoris.core.domain.valueObjects.ORCiD; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.neo4j.core.schema.GeneratedValue; import org.springframework.data.neo4j.core.schema.Node; import org.springframework.data.neo4j.core.support.UUIDStringGenerator; @@ -36,17 +38,17 @@ @Setter @AllArgsConstructor @RequiredArgsConstructor -@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION, property = "type") -@JsonSubTypes({ - @JsonSubTypes.Type(value = ORCiDUser.class, name = "orcid"), - @JsonSubTypes.Type(value = TextUser.class, name = "text") -}) -public abstract sealed class User implements Serializable permits ORCiDUser, TextUser { +public class User implements Serializable { @CreatedDate Instant createdAt; + + @LastModifiedDate + Instant updatedAt; + @Id @GeneratedValue(UUIDStringGenerator.class) private String internalId; - private String type; + private Name name; + private EmailAddress email; + private ORCiD orcid; } - diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/enums/CombinationOptions.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/enums/CombinationOptions.java similarity index 93% rename from src/main/java/edu/kit/datamanager/idoris/domain/enums/CombinationOptions.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/enums/CombinationOptions.java index c79ffdd..3e42555 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/enums/CombinationOptions.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/enums/CombinationOptions.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.enums; +package edu.kit.datamanager.idoris.core.domain.enums; import lombok.AllArgsConstructor; import lombok.Getter; @@ -27,4 +27,4 @@ public enum CombinationOptions { ANY("any"), ALL("all"); private final String jsonName; -} +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/enums/ExecutionMode.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/enums/ExecutionMode.java similarity index 92% rename from src/main/java/edu/kit/datamanager/idoris/domain/enums/ExecutionMode.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/enums/ExecutionMode.java index cc91fdd..71c2da8 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/enums/ExecutionMode.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/enums/ExecutionMode.java @@ -14,9 +14,9 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.enums; +package edu.kit.datamanager.idoris.core.domain.enums; public enum ExecutionMode { sync, async -} +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/enums/PrimitiveDataTypes.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/enums/PrimitiveDataTypes.java similarity index 97% rename from src/main/java/edu/kit/datamanager/idoris/domain/enums/PrimitiveDataTypes.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/enums/PrimitiveDataTypes.java index e3e2bcf..35e9778 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/enums/PrimitiveDataTypes.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/enums/PrimitiveDataTypes.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.enums; +package edu.kit.datamanager.idoris.core.domain.enums; import lombok.AllArgsConstructor; import lombok.Getter; @@ -52,4 +52,4 @@ public boolean isValueValid(Object value) { value instanceof Boolean || (value instanceof String string && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))); }; } -} +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/AbstractValueObject.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/AbstractValueObject.java new file mode 100644 index 0000000..aeb2c63 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/AbstractValueObject.java @@ -0,0 +1,47 @@ +/* + * 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.idoris.core.domain.valueObjects; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public abstract class AbstractValueObject { + private final T unvalidated; + + @JsonCreator + public AbstractValueObject(T unvalidated) { + if (unvalidated == null) { + throw new NullPointerException("Value object cannot be null"); + } + this.unvalidated = unvalidated; + } + + protected T getUnvalidated() { + return this.unvalidated; + } + + @Override + public abstract int hashCode(); + + @Override + public abstract boolean equals(Object o); + + @Override + @JsonValue + public abstract String toString(); +} diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/AttributeMapping.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/AttributeMapping.java similarity index 70% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/AttributeMapping.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/AttributeMapping.java index 75ba923..b7527c4 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/AttributeMapping.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/AttributeMapping.java @@ -14,15 +14,18 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain.valueObjects; -import edu.kit.datamanager.idoris.domain.VisitableElement; +import edu.kit.datamanager.idoris.core.domain.Attribute; import edu.kit.datamanager.idoris.rules.logic.RuleOutput; +import edu.kit.datamanager.idoris.rules.logic.VisitableElement; import edu.kit.datamanager.idoris.rules.logic.Visitor; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Id; import org.springframework.data.neo4j.core.schema.Node; import org.springframework.data.neo4j.core.schema.Relationship; @@ -33,7 +36,11 @@ @Getter @AllArgsConstructor @NoArgsConstructor -public class AttributeMapping extends VisitableElement implements Serializable { +public class AttributeMapping implements VisitableElement, Serializable { + @Id + @GeneratedValue(GeneratedValue.UUIDGenerator.class) + private String internalId; + private String name; @Relationship(value = "input", direction = Relationship.Direction.INCOMING) @@ -47,7 +54,12 @@ public class AttributeMapping extends VisitableElement implements Serializable { private Attribute output; @Override - protected > T accept(Visitor visitor, Object... args) { + public String getId() { + return ""; + } + + @Override + public > T accept(Visitor visitor, Object... args) { return visitor.visit(this, args); } -} +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/EmailAddress.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/EmailAddress.java new file mode 100644 index 0000000..c8a8dd9 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/EmailAddress.java @@ -0,0 +1,73 @@ +/* + * 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.idoris.core.domain.valueObjects; + +/** + * Value object representing an email address with validation. + * Validates the email format and length upon instantiation. + * Uses a regex pattern to ensure the email adheres to standard formats. + * Note: This regex is a simplified version and may not cover all edge cases. + */ +public final class EmailAddress extends AbstractValueObject { + private static final String EMAIL_REGEX = "^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])$"; + private final String email; + + /** + * Constructor accepting an email string and validating it. + * + * @param email The email address to validate. + * @throws IllegalArgumentException if the email is null, blank, too short, too long, or invalid. + */ + public EmailAddress(String email) { + super(email); + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + + email = email.trim(); + + if (email.length() < 5) { + throw new IllegalArgumentException("Email must be at least 5 characters long"); + } + if (email.length() > 255) { + throw new IllegalArgumentException("Email cannot be longer than 255 characters"); + } + + if (!email.matches(EMAIL_REGEX)) { + throw new IllegalArgumentException("Invalid email format"); + } + + this.email = email; + } + + @Override + public int hashCode() { + return email.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof EmailAddress that)) return false; + + return email.equals(that.email); + } + + @Override + public String toString() { + return email; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/Name.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/Name.java new file mode 100644 index 0000000..0aeaaad --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/Name.java @@ -0,0 +1,59 @@ +/* + * 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.idoris.core.domain.valueObjects; + +public final class Name extends AbstractValueObject { + private final String name; + + public Name(String name) { + super(name); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Name cannot be null or blank"); + } + + // Allow harmless characters, remove others + name = name.replaceAll("[^\\p{L}\\p{N} .,\\-_#@'&:;€$£¥()]", ""); + + name = name.trim(); + + if (name.length() < 3) { + throw new IllegalArgumentException("Name must be at least 3 characters long"); + } + if (name.length() > 255) { + throw new IllegalArgumentException("Name cannot be longer than 255 characters"); + } + + this.name = name; + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Name name1)) return false; + + return name.equals(name1.name); + } + + @Override + public String toString() { + return name; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/ORCiD.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/ORCiD.java new file mode 100644 index 0000000..6072e1c --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/ORCiD.java @@ -0,0 +1,194 @@ +/* + * 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.idoris.core.domain.valueObjects; + +import lombok.extern.slf4j.Slf4j; + +import java.net.URI; +import java.net.URL; +import java.util.regex.Pattern; + +/** + * Value object representing a validated ORCiD. + * Ensures the ORCiD is in the correct format and has a valid check digit. + * Note: This class does not verify the existence of the ORCiD in the ORCiD registry. + */ +@Slf4j +public final class ORCiD extends AbstractValueObject { + private static final String ORCID_REGEX = "^(\\d{4}-\\d{4}-\\d{4}-[(\\d{3}\\dX)(\\d{4})])$"; + private static final String ORCID_URL_REGEX = "^(https?://)?((.+\\.)?orcid\\.org)/(\\d{4}-\\d{4}-\\d{4}-[(\\d{3}\\dX)(\\d{4})])$"; + + private final URL orcid; + + /** + * Constructor accepting an unvalidated URL. + * + * @param unvalidated The unvalidated ORCiD URL. + * @throws IllegalArgumentException if the URL is null or invalid. + */ + public ORCiD(URL unvalidated) { + super(unvalidated.toString()); + if (unvalidated == null) { + throw new IllegalArgumentException("ORCiD URL cannot be null"); + } + this.orcid = this.getValidORCiDURL(unvalidated.getHost(), unvalidated.getPath()); + } + + /** + * Validates the ORCiD components and constructs a URL. + * + * @param host The host part of the ORCiD URL. + * @param path The path part of the ORCiD URL. + * @return A validated ORCiD URL. + * @throws IllegalArgumentException if validation fails. + */ + private URL getValidORCiDURL(String host, String path) { + String orcidId = validate(host, path); + URI uri = URI.create(String.format("https://%s/%s", host, orcidId)); + try { + return uri.toURL(); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to create ORCiD URL", e); + } + } + + /** + * Validates the ORCiD format and check digit. + * + * @param host The ORCiD registry used (e.g., orcid.org or sandbox.orcid.org). Must end with orcid.org. + * @param path The ORCiD identifier path (e.g., 0000-0002-1825-0097). + * @return The validated ORCiD identifier. + * @throws IllegalArgumentException if validation fails. + */ + private String validate(String host, String path) { + if (host == null || host.isBlank() || !host.endsWith("orcid.org")) { + throw new IllegalArgumentException("ORCiD URL must have a valid host ending with orcid.org"); + } + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("ORCiD URL must have a valid path"); + } + String orcidId = path.startsWith("/") ? path.substring(1) : path; + if (!orcidId.matches(ORCID_REGEX)) { + throw new IllegalArgumentException("Invalid ORCiD format"); + } + + String[] parts = orcidId.split("-"); + String baseDigits = String.join("", parts).substring(0, 15); + char expectedCheckDigit = generateCheckDigit(baseDigits.toCharArray()); + char actualCheckDigit = parts[3].substring(3).charAt(0); + if (expectedCheckDigit != actualCheckDigit) { + throw new IllegalArgumentException("Invalid ORCiD check digit"); + } + + return orcidId; + } + + /** + * Generates the check digit for the given base digits using the ISO 7064 Mod 11-2 algorithm. + * Provided in the ORCiD specification: Structure of the ORCID Identifier + * + * @param baseDigits The first 15 digits of the ORCiD identifier. Without dashes and check digit. + * @return The calculated check digit as a char ("0"-"9" or "X"). + */ + private Character generateCheckDigit(char[] baseDigits) { + if (baseDigits == null || baseDigits.length != 15) { + throw new IllegalArgumentException("Base digits must be exactly 15 characters long"); + } + + int total = 0; + for (int i = 0; i < 15; i++) { + if (baseDigits[i] < '0' || baseDigits[i] > '9') { + throw new IllegalArgumentException("Base digits must be numeric"); + } + int digit = Character.getNumericValue(baseDigits[i]); + total = (total + digit) * 2; + } + int remainder = total % 11; + int result = (12 - remainder) % 11; + return result == 10 ? 'X' : (char) result; + } + + /** + * Constructor accepting an unvalidated string. + * The string can be either a full ORCiD URL or just the ORCiD ID. + * + * @param unvalidatedInput The unvalidated ORCiD input string. + * @throws IllegalArgumentException if the input is invalid. + */ +// @JsonCreator + public ORCiD(String unvalidatedInput) { + super(unvalidatedInput); + if (unvalidatedInput == null || unvalidatedInput.isBlank()) { + throw new IllegalArgumentException("ORCiD input cannot be null or blank"); + } + + // Use the grouping in the regex to extract host and path + Pattern pattern = Pattern.compile(ORCID_URL_REGEX); + var matcher = pattern.matcher(unvalidatedInput); + if (matcher.matches()) { + // If a full URL is provided, extract host and path + String host = matcher.group(2); + String path = matcher.group(4); + this.orcid = this.getValidORCiDURL(host, path); + } else if (unvalidatedInput.matches(ORCID_REGEX)) { + // If only the ORCiD ID is provided, construct the full URL using the default host + String host = "orcid.org"; + this.orcid = this.getValidORCiDURL(host, unvalidatedInput); + } else { + throw new IllegalArgumentException("Invalid ORCiD input format: Either full URL or ORCiD ID string must be provided"); + } + } + + /** + * Returns the validated ORCiD URL. + * e.g., https://orcid.org/0000-0002-1825-0097 + * + * @return The ORCiD URL. + */ + public URL get() { + return orcid; + } + + /** + * Returns the ORCiD identifier without the URL part. + * + * @return The ORCiD ID string (e.g., 0000-0002-1825-0097). + */ + public String getORCiDIDSubstring() { + String path = orcid.getPath(); + return path.startsWith("/") ? path.substring(1) : path; + } + + @Override + public int hashCode() { + return orcid.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ORCiD orCiD)) return false; + + return orcid.equals(orCiD.orcid); + } + + @Override +// @JsonValue + public String toString() { + return orcid.toExternalForm(); + } + +} diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/OperationStep.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/OperationStep.java similarity index 71% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/OperationStep.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/OperationStep.java index 3b141a4..800aca2 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/OperationStep.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/OperationStep.java @@ -14,16 +14,20 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain.valueObjects; -import edu.kit.datamanager.idoris.domain.VisitableElement; -import edu.kit.datamanager.idoris.domain.enums.ExecutionMode; +import edu.kit.datamanager.idoris.core.domain.Operation; +import edu.kit.datamanager.idoris.core.domain.TechnologyInterface; +import edu.kit.datamanager.idoris.core.domain.enums.ExecutionMode; import edu.kit.datamanager.idoris.rules.logic.RuleOutput; +import edu.kit.datamanager.idoris.rules.logic.VisitableElement; import edu.kit.datamanager.idoris.rules.logic.Visitor; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Id; import org.springframework.data.neo4j.core.schema.Node; import org.springframework.data.neo4j.core.schema.Relationship; @@ -35,7 +39,11 @@ @RequiredArgsConstructor @Getter @Setter -public class OperationStep extends VisitableElement implements Serializable { +public class OperationStep implements VisitableElement, Serializable { + @Id + @GeneratedValue(GeneratedValue.UUIDGenerator.class) + private String internalId; + private Integer index; private String name; @@ -58,7 +66,12 @@ public class OperationStep extends VisitableElement implements Serializable { private List outputMappings; @Override - protected > T accept(Visitor visitor, Object... args) { + public String getId() { + return internalId; + } + + @Override + public > T accept(Visitor visitor, Object... args) { return visitor.visit(this, args); } -} +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/PID.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/PID.java new file mode 100644 index 0000000..f531853 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/PID.java @@ -0,0 +1,118 @@ +/* + * 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.idoris.core.domain.valueObjects; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.regex.Pattern; + +/** + * Value object representing a persistent identifier (PID) in the form of a handle. + * A valid PID must match the pattern: /, where: + * - consists of alphanumeric characters and dots (e.g., "12345.6789") + * - consists of printable ASCII characters (from '!' to '~') + * Note: This class does not verify the existence of the PID in any registry. + */ +public final class PID extends AbstractValueObject { + private static final String HANDLE_REGEX = "^([0-9A-Za-z]+(\\.[0-9A-Za-z]+)*)/([!-~]+)$"; + private final String prefix; + private final String suffix; + + /** + * Constructor accepting an unvalidated PID string. + * + * @param unvalidated The unvalidated PID string. + * @throws IllegalArgumentException if the PID is null, blank, or invalid. + */ + @JsonCreator + public PID(String unvalidated) { + super(unvalidated); + if (unvalidated.isBlank()) { + throw new IllegalArgumentException("PID cannot be null or blank."); + } + + Pattern pattern = Pattern.compile(HANDLE_REGEX); + var matcher = pattern.matcher(unvalidated); + if (!matcher.matches()) { + throw new IllegalArgumentException("PID must be of the form / with valid characters."); + } + + if (matcher.groupCount() < 2) { + throw new IllegalArgumentException("PID must contain both a prefix and a suffix."); + } + + // Split into prefix and suffix using the regex groups + this.prefix = matcher.group(1); // The prefix part is the second capturing group + this.suffix = matcher.group(3); // The suffix part is the last capturing group + } + + /** + * This method is only necessary for the neo4j OGM to work properly. + * It returns a valid PID string. + * + * @return The validated PID string. + */ + public String getUnvalidated() { + return get(); + } + + @Override + public int hashCode() { + int result = getPrefix().hashCode(); + result = 31 * result + getSuffix().hashCode(); + return result; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof PID pid)) return false; + + return getPrefix().equals(pid.getPrefix()) && getSuffix().equals(pid.getSuffix()); + } + + @Override + public String toString() { + return get(); + } + + /** + * Returns the prefix part of the PID. + * + * @return The prefix. + */ + public String getPrefix() { + return prefix; + } + + /** + * Returns the suffix part of the PID. + * + * @return The suffix. + */ + public String getSuffix() { + return suffix; + } + + /** + * Returns the full PID string in the format /. + * + * @return The full PID. + */ + public String get() { + return String.format("%s/%s", prefix, suffix); + } +} diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/Reference.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/Reference.java similarity index 83% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/Reference.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/Reference.java index b16cef5..af9e450 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/Reference.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/domain/valueObjects/Reference.java @@ -14,8 +14,8 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.core.domain.valueObjects; -public record Reference(String relationType, String targetPID) { -} +public record Reference(PID relationType, PID targetPID) { +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/AbstractDomainEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/AbstractDomainEvent.java new file mode 100644 index 0000000..5edc964 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/AbstractDomainEvent.java @@ -0,0 +1,54 @@ +/* + * 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.idoris.core.events; + +import lombok.Getter; +import lombok.ToString; + +import java.time.Instant; +import java.util.UUID; + +/** + * Abstract base class for all domain events. + * Provides common functionality and properties for all events. + */ +@Getter +@ToString +public abstract class AbstractDomainEvent implements DomainEvent { + private final Instant timestamp = Instant.now(); + private final String eventId = UUID.randomUUID().toString(); + + /** + * Gets the timestamp when this event occurred. + * + * @return the instant when the event was created + */ + @Override + public Instant getTimestamp() { + return timestamp; + } + + /** + * Gets the unique identifier for this event. + * + * @return the event's unique identifier + */ + @Override + public String getEventId() { + return eventId; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/DomainEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/DomainEvent.java new file mode 100644 index 0000000..e8dc25c --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/DomainEvent.java @@ -0,0 +1,40 @@ +/* + * 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.idoris.core.events; + +import java.time.Instant; + +/** + * Base interface for all domain events in the system. + * Domain events represent significant occurrences within the domain that + * other parts of the application might be interested in. + */ +public interface DomainEvent { + /** + * Gets the timestamp when this event occurred. + * + * @return the instant when the event was created + */ + Instant getTimestamp(); + + /** + * Gets the unique identifier for this event. + * + * @return the event's unique identifier + */ + String getEventId(); +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityCreatedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityCreatedEvent.java new file mode 100644 index 0000000..009255f --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityCreatedEvent.java @@ -0,0 +1,52 @@ +/* + * 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.idoris.core.events; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import lombok.Getter; +import lombok.ToString; + +/** + * Event that is published when a new entity is created in the system. + * This event carries the newly created entity and can be used by listeners + * to perform additional operations like PID generation, validation, etc. + * + * @param the type of entity that was created, must extend AdministrativeMetadata + */ +@Getter +@ToString(callSuper = true) +public class EntityCreatedEvent extends AbstractDomainEvent { + private final T entity; + + /** + * Creates a new EntityCreatedEvent for the given entity. + * + * @param entity the newly created entity + */ + public EntityCreatedEvent(T entity) { + this.entity = entity; + } + + /** + * Gets the entity that was created. + * + * @return the newly created entity + */ + public T getEntity() { + return entity; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityDeletedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityDeletedEvent.java new file mode 100644 index 0000000..fad22c4 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityDeletedEvent.java @@ -0,0 +1,74 @@ +/* + * 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.idoris.core.events; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import lombok.Getter; +import lombok.ToString; + +/** + * Event that is published when an entity is deleted from the system. + * This event carries the deleted entity and can be used by listeners + * to perform additional operations like cleanup, notification, etc. + * + * @param the type of entity that was deleted, must extend AdministrativeMetadata + */ +@Getter +@ToString(callSuper = true) +public class EntityDeletedEvent extends AbstractDomainEvent { + private final T entity; + private final String entityType; + private final String entityInternalId; + + /** + * Creates a new EntityDeletedEvent for the given entity. + * + * @param entity the deleted entity + */ + public EntityDeletedEvent(T entity) { + this.entity = entity; + this.entityType = entity.getClass().getSimpleName(); + this.entityInternalId = entity.getInternalId(); + } + + /** + * Gets the entity that was deleted. + * + * @return the deleted entity + */ + public T getEntity() { + return entity; + } + + /** + * Gets the type of the entity that was deleted. + * + * @return the entity type + */ + public String getEntityType() { + return entityType; + } + + /** + * Gets the internal ID of the entity that was deleted. + * + * @return the entity internal ID + */ + public String getEntityInternalId() { + return entityInternalId; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityImportedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityImportedEvent.java new file mode 100644 index 0000000..6bdb74b --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityImportedEvent.java @@ -0,0 +1,118 @@ +/* + * 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.idoris.core.events; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import lombok.Getter; +import lombok.ToString; + +/** + * Event that is published when an entity is imported from an external system. + * This event carries the imported entity, the source system, and import metadata. + * It can be used by listeners to perform additional operations like validation, enrichment, or notification. + * + * @param the type of entity that was imported, must extend AdministrativeMetadata + */ +@Getter +@ToString(callSuper = true) +public class EntityImportedEvent extends AbstractDomainEvent { + private final T entity; + private final String sourceSystem; + private final String sourceIdentifier; + private final ImportResult importResult; + + /** + * Creates a new EntityImportedEvent for the given entity and source information. + * + * @param entity the imported entity + * @param sourceSystem the system from which the entity was imported + * @param sourceIdentifier the identifier of the entity in the source system + * @param importResult the result of the import operation + */ + public EntityImportedEvent(T entity, String sourceSystem, String sourceIdentifier, ImportResult importResult) { + this.entity = entity; + this.sourceSystem = sourceSystem; + this.sourceIdentifier = sourceIdentifier; + this.importResult = importResult; + } + + /** + * Gets the entity that was imported. + * + * @return the imported entity + */ + public T getEntity() { + return entity; + } + + /** + * Gets the system from which the entity was imported. + * + * @return the source system + */ + public String getSourceSystem() { + return sourceSystem; + } + + /** + * Gets the identifier of the entity in the source system. + * + * @return the source identifier + */ + public String getSourceIdentifier() { + return sourceIdentifier; + } + + /** + * Gets the result of the import operation. + * + * @return the import result + */ + public ImportResult getImportResult() { + return importResult; + } + + /** + * Enum representing the result of an import operation. + */ + public enum ImportResult { + /** + * The entity was successfully imported. + */ + SUCCESS, + + /** + * The entity was partially imported with some data loss or modifications. + */ + PARTIAL, + + /** + * The entity was imported but requires manual review. + */ + NEEDS_REVIEW, + + /** + * The entity was not imported due to validation errors. + */ + VALIDATION_ERROR, + + /** + * The entity was not imported due to a system error. + */ + SYSTEM_ERROR + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityPatchedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityPatchedEvent.java new file mode 100644 index 0000000..5fada1f --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityPatchedEvent.java @@ -0,0 +1,44 @@ +/* + * 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.idoris.core.events; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import lombok.Getter; + +/** + * Event that is published when an entity is partially updated (patched) in the system. + * This event carries the patched entity and can be used by listeners + * to react to entity patch operations. + * + * @param the type of entity that was patched + */ +@Getter +public class EntityPatchedEvent extends AbstractDomainEvent { + private final T entity; + private final Long previousVersion; + + /** + * Creates a new EntityPatchedEvent for the given entity. + * + * @param entity the patched entity + * @param previousVersion the version of the entity before the patch + */ + public EntityPatchedEvent(T entity, Long previousVersion) { + this.entity = entity; + this.previousVersion = previousVersion; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityUpdatedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityUpdatedEvent.java new file mode 100644 index 0000000..96ecc94 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EntityUpdatedEvent.java @@ -0,0 +1,64 @@ +/* + * 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.idoris.core.events; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import lombok.Getter; +import lombok.ToString; + +/** + * Event that is published when an entity is updated in the system. + * This event carries the updated entity and can be used by listeners + * to perform additional operations like versioning, validation, etc. + * + * @param the type of entity that was updated, must extend AdministrativeMetadata + */ +@Getter +@ToString(callSuper = true) +public class EntityUpdatedEvent extends AbstractDomainEvent { + private final T entity; + private final Long previousVersion; + + /** + * Creates a new EntityUpdatedEvent for the given entity. + * + * @param entity the updated entity + * @param previousVersion the version of the entity before the update + */ + public EntityUpdatedEvent(T entity, Long previousVersion) { + this.entity = entity; + this.previousVersion = previousVersion; + } + + /** + * Gets the entity that was updated. + * + * @return the updated entity + */ + public T getEntity() { + return entity; + } + + /** + * Gets the version of the entity before the update. + * + * @return the previous version number + */ + public Long getPreviousVersion() { + return previousVersion; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EventPublisherService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EventPublisherService.java new file mode 100644 index 0000000..254c1a8 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/EventPublisherService.java @@ -0,0 +1,152 @@ +/* + * 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.idoris.core.events; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +/** + * Service for publishing domain events. + * This logic wraps Spring's ApplicationEventPublisher to provide a more domain-specific API. + */ +@Service +@Slf4j +@Observed(contextualName = "eventPublisherService") +public class EventPublisherService { + private final ApplicationEventPublisher eventPublisher; + + /** + * Creates a new EventPublisherService with the given ApplicationEventPublisher. + * + * @param eventPublisher the Spring ApplicationEventPublisher + */ + public EventPublisherService(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + /** + * Publishes an entity created event. + * + * @param entity the newly created entity + * @param the type of entity + */ + @WithSpan(kind = SpanKind.PRODUCER) + @Timed(value = "eventPublisherService.publishEntityCreated", description = "Time taken to publish entity created event", histogram = true) + @Counted(value = "eventPublisherService.publishEntityCreated.count", description = "Number of entity created events published") + public void publishEntityCreated(T entity) { + log.debug("Publishing EntityCreatedEvent for entity: {}", entity); + eventPublisher.publishEvent(new EntityCreatedEvent<>(entity)); + } + + + /** + * Publishes an entity updated event. + * + * @param entity the updated entity + * @param previousVersion the version of the entity before the update + * @param the type of entity + */ + @WithSpan(kind = SpanKind.PRODUCER) + @Timed(value = "eventPublisherService.publishEntityUpdated", description = "Time taken to publish entity updated event", histogram = true) + @Counted(value = "eventPublisherService.publishEntityUpdated.count", description = "Number of entity updated events published") + public void publishEntityUpdated(T entity, @SpanAttribute("entity.previousVersion") Long previousVersion) { + log.debug("Publishing EntityUpdatedEvent for entity: {}, previous version: {}", entity, previousVersion); + eventPublisher.publishEvent(new EntityUpdatedEvent<>(entity, previousVersion)); + } + + /** + * Publishes an ID generated event. + * Assumes that the ID is newly generated. + * + * @param entity the entity for which the ID was generated + * @param id the generated ID + * @param the type of entity + */ + @WithSpan(kind = SpanKind.PRODUCER) + @Timed(value = "eventPublisherService.publishIDGeneratedShort", description = "Time taken to publish ID generated event (short form)", histogram = true) + @Counted(value = "eventPublisherService.publishIDGeneratedShort.count", description = "Number of ID generated events published (short form)") + public void publishIDGenerated(T entity, @SpanAttribute("entity.id") String id) { + publishIDGenerated(entity, id, true); + } + + /** + * Publishes an ID generated event. + * + * @param entity the entity for which the ID was generated + * @param id the generated ID + * @param isNewID indicates whether this is a newly generated ID or an existing one + * @param the type of entity + */ + @WithSpan(kind = SpanKind.PRODUCER) + @Timed(value = "eventPublisherService.publishIDGenerated", description = "Time taken to publish ID generated event", histogram = true) + @Counted(value = "eventPublisherService.publishIDGenerated.count", description = "Number of ID generated events published") + public void publishIDGenerated(T entity, @SpanAttribute("entity.id") String id, @SpanAttribute("entity.isNewID") boolean isNewID) { + log.debug("Publishing IDGeneratedEvent for entity: {}, ID: {}, isNewID: {}", entity, id, isNewID); + eventPublisher.publishEvent(new PIDGeneratedEvent<>(entity, id, isNewID)); + } + + /** + * Publishes an entity patched event. + * + * @param entity the patched entity + * @param previousVersion the version of the entity before the patch + * @param the type of entity + */ + @WithSpan(kind = SpanKind.PRODUCER) + @Timed(value = "eventPublisherService.publishEntityPatched", description = "Time taken to publish entity patched event", histogram = true) + @Counted(value = "eventPublisherService.publishEntityPatched.count", description = "Number of entity patched events published") + public void publishEntityPatched(T entity, @SpanAttribute("entity.previousVersion") Long previousVersion) { + log.debug("Publishing EntityPatchedEvent for entity: {}, previous version: {}", entity, previousVersion); + eventPublisher.publishEvent(new EntityPatchedEvent<>(entity, previousVersion)); + } + + + /** + * Publishes a generic domain event. + * + * @param event the event to publish + */ + @WithSpan(kind = SpanKind.PRODUCER) + @Timed(value = "eventPublisherService.publishEvent", description = "Time taken to publish generic domain event", histogram = true) + @Counted(value = "eventPublisherService.publishEvent.count", description = "Number of generic domain events published") + public void publishEvent(DomainEvent event) { + log.debug("Publishing event: {}", event); + eventPublisher.publishEvent(event); + } + + /** + * Publishes an entity deleted event. + * + * @param entity the deleted entity + * @param the type of entity + */ + @WithSpan(kind = SpanKind.PRODUCER) + @Timed(value = "eventPublisherService.publishEntityDeleted", description = "Time taken to publish entity deleted event", histogram = true) + @Counted(value = "eventPublisherService.publishEntityDeleted.count", description = "Number of entity deleted events published") + public void publishEntityDeleted(T entity) { + log.debug("Publishing EntityDeletedEvent for entity: {}", entity); + eventPublisher.publishEvent(new EntityDeletedEvent<>(entity)); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/PIDGeneratedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/PIDGeneratedEvent.java new file mode 100644 index 0000000..5bfa456 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/PIDGeneratedEvent.java @@ -0,0 +1,109 @@ +/* + * 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.idoris.core.events; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import lombok.Getter; +import lombok.ToString; + +/** + * Event that is published when an ID is generated for an entity. + * This event carries the entity and the generated ID, and can be used by listeners + * to perform additional operations like ID record creation, indexing, etc. + * + * @param the type of entity for which the ID was generated, must extend AdministrativeMetadata + */ +@Getter +@ToString(callSuper = true) +public class PIDGeneratedEvent extends AbstractDomainEvent { + private final T entity; + private final String id; + private final boolean isNewID; + private final String entityInternalId; + private final String entityType; + + /** + * Creates a new PIDGeneratedEvent for the given entity and ID. + * Assumes that the ID is newly generated. + * + * @param entity the entity for which the ID was generated + * @param id the generated ID + */ + public PIDGeneratedEvent(T entity, String id) { + this(entity, id, true); + } + + /** + * Creates a new PIDGeneratedEvent for the given entity and ID. + * + * @param entity the entity for which the ID was generated + * @param id the generated ID + * @param isNewID indicates whether this is a newly generated ID or an existing one + */ + public PIDGeneratedEvent(T entity, String id, boolean isNewID) { + this.entity = entity; + this.id = id; + this.isNewID = isNewID; + this.entityInternalId = entity.getInternalId(); + this.entityType = entity.getClass().getSimpleName(); + } + + /** + * Gets the entity for which the ID was generated. + * + * @return the entity + */ + public T getEntity() { + return entity; + } + + /** + * Gets the generated ID. + * + * @return the ID + */ + public String getId() { + return id; + } + + /** + * Indicates whether this is a newly generated ID or an existing one. + * + * @return true if the ID was newly generated, false if it already existed + */ + public boolean isNewID() { + return isNewID; + } + + /** + * Gets the internal ID of the entity for which the ID was generated. + * + * @return the entity internal ID + */ + public String getEntityInternalId() { + return entityInternalId; + } + + /** + * Gets the type of the entity for which the ID was generated. + * + * @return the entity type + */ + public String getEntityType() { + return entityType; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/SchemaGeneratedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/SchemaGeneratedEvent.java new file mode 100644 index 0000000..1259ce6 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/SchemaGeneratedEvent.java @@ -0,0 +1,86 @@ +/* + * 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.idoris.core.events; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import lombok.Getter; +import lombok.ToString; + +/** + * Event that is published when a schema is generated for an entity. + * This event carries the entity for which the schema was generated, the schema format, and the schema content. + * It can be used by listeners to perform additional operations like schema validation, storage, or publication. + */ +@Getter +@ToString(callSuper = true) +public class SchemaGeneratedEvent extends AbstractDomainEvent { + private final AdministrativeMetadata entity; + private final String schemaFormat; + private final String schemaContent; + private final boolean isValid; + + /** + * Creates a new SchemaGeneratedEvent for the given entity and schema. + * + * @param entity the entity for which the schema was generated + * @param schemaFormat the format of the schema (e.g., "json-schema", "xml-schema") + * @param schemaContent the content of the schema + * @param isValid indicates whether the schema is valid + */ + public SchemaGeneratedEvent(AdministrativeMetadata entity, String schemaFormat, String schemaContent, boolean isValid) { + this.entity = entity; + this.schemaFormat = schemaFormat; + this.schemaContent = schemaContent; + this.isValid = isValid; + } + + /** + * Gets the entity for which the schema was generated. + * + * @return the entity + */ + public AdministrativeMetadata getEntity() { + return entity; + } + + /** + * Gets the format of the schema. + * + * @return the schema format + */ + public String getSchemaFormat() { + return schemaFormat; + } + + /** + * Gets the content of the schema. + * + * @return the schema content + */ + public String getSchemaContent() { + return schemaContent; + } + + /** + * Indicates whether the schema is valid. + * + * @return true if the schema is valid, false otherwise + */ + public boolean isValid() { + return isValid; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/VersionCreatedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/VersionCreatedEvent.java new file mode 100644 index 0000000..5aee872 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/events/VersionCreatedEvent.java @@ -0,0 +1,101 @@ +/* + * 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.idoris.core.events; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import lombok.Getter; +import lombok.ToString; + +import java.util.Map; + +/** + * Event that is published when a new version of an entity is created. + * This event carries the current entity, the previous version, and change information. + * It can be used by listeners to perform additional operations like version tracking, notification, or audit logging. + * + * @param the type of entity that was versioned, must extend AdministrativeMetadata + */ +@Getter +@ToString(callSuper = true) +public class VersionCreatedEvent extends AbstractDomainEvent { + private final T currentEntity; + private final T previousEntity; + private final Long previousVersion; + private final Long currentVersion; + private final Map changes; + + /** + * Creates a new VersionCreatedEvent for the given entity versions and changes. + * + * @param currentEntity the current version of the entity + * @param previousEntity the previous version of the entity + * @param changes a map of field names to their changed values + */ + public VersionCreatedEvent(T currentEntity, T previousEntity, Map changes) { + this.currentEntity = currentEntity; + this.previousEntity = previousEntity; + this.previousVersion = previousEntity.getVersion(); + this.currentVersion = currentEntity.getVersion(); + this.changes = changes; + } + + /** + * Gets the current version of the entity. + * + * @return the current entity + */ + public T getCurrentEntity() { + return currentEntity; + } + + /** + * Gets the previous version of the entity. + * + * @return the previous entity + */ + public T getPreviousEntity() { + return previousEntity; + } + + /** + * Gets the version number of the previous entity. + * + * @return the previous version number + */ + public Long getPreviousVersion() { + return previousVersion; + } + + /** + * Gets the version number of the current entity. + * + * @return the current version number + */ + public Long getCurrentVersion() { + return currentVersion; + } + + /** + * Gets the changes between the previous and current versions. + * The map contains field names as keys and their changed values as values. + * + * @return the changes map + */ + public Map getChanges() { + return changes; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/exceptions/ValidationException.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/exceptions/ValidationException.java new file mode 100644 index 0000000..ac5400b --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/exceptions/ValidationException.java @@ -0,0 +1,33 @@ +/* + * 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.idoris.core.exceptions; + +import edu.kit.datamanager.idoris.rules.validation.ValidationResult; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@Getter +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class ValidationException extends RuntimeException { + private final ValidationResult validationResult; + + public ValidationException(String message, ValidationResult validationResult) { + super(message); + this.validationResult = validationResult; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/exceptions/ValidationExceptionHandler.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/exceptions/ValidationExceptionHandler.java new file mode 100644 index 0000000..4523eb0 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/exceptions/ValidationExceptionHandler.java @@ -0,0 +1,35 @@ +/* + * 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.idoris.core.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; + +@ControllerAdvice +public class ValidationExceptionHandler { + + @ExceptionHandler(ValidationException.class) + public ProblemDetail handleValidationException(ValidationException ex, WebRequest request) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + problemDetail.setTitle("Validation failed"); + problemDetail.setProperty("validationResult", ex.getValidationResult().getOutputMessages()); + return problemDetail; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/package-info.java new file mode 100644 index 0000000..fa1b4b3 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/package-info.java @@ -0,0 +1,31 @@ +/* + * 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. + */ + +/** + * Core module for IDORIS. + * This module contains base abstractions, common interfaces, and cross-cutting concerns. + * It also includes the event infrastructure for the event-driven architecture. + * + *

    The core module is a foundational module that other modules depend on. + * It should not depend on any other module to avoid circular dependencies.

    + */ +@ApplicationModule( + displayName = "IDORIS Core", + type = ApplicationModule.Type.OPEN +) +package edu.kit.datamanager.idoris.core; + +import org.springframework.modulith.ApplicationModule; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/core/web/hateoas/EntityModelAssembler.java b/idoris/src/main/java/edu/kit/datamanager/idoris/core/web/hateoas/EntityModelAssembler.java new file mode 100644 index 0000000..5ab91eb --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/core/web/hateoas/EntityModelAssembler.java @@ -0,0 +1,50 @@ +/* + * 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.idoris.core.web.hateoas; + +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.server.RepresentationModelAssembler; + +/** + * Base interface for entity model assemblers. + * This interface defines the contract for assemblers that convert entities to EntityModel objects with HATEOAS links. + * + * @param the entity type, must extend AdministrativeMetadata + */ +public interface EntityModelAssembler extends RepresentationModelAssembler> { + + /** + * Converts an entity to an EntityModel with HATEOAS links. + * This method is implemented by the RepresentationModelAssembler interface. + * + * @param entity the entity to convert + * @return an EntityModel containing the entity and links + */ + @Override + EntityModel toModel(T entity); + + /** + * Creates an EntityModel for the given entity without adding links. + * This method can be used as a base for the toModel method. + * + * @param entity the entity to convert + * @return an EntityModel containing the entity without links + */ + default EntityModel toModelWithoutLinks(T entity) { + return EntityModel.of(entity); + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/IAtomicDataTypeExternalService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/IAtomicDataTypeExternalService.java new file mode 100644 index 0000000..71e4089 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/IAtomicDataTypeExternalService.java @@ -0,0 +1,43 @@ +/* + * 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.idoris.datatypes.api; + +import edu.kit.datamanager.idoris.datatypes.dto.AtomicDataTypeDto; + +import java.util.List; +import java.util.Optional; + +/** + * External API for AtomicDataType operations (DTO-first). + */ +public interface IAtomicDataTypeExternalService { + AtomicDataTypeDto create(AtomicDataTypeDto dto); + + AtomicDataTypeDto update(String id, AtomicDataTypeDto dto); + + AtomicDataTypeDto patch(String id, AtomicDataTypeDto dto); + + void delete(String id); + + Optional get(String id); + + List list(); + + // Relationship operations + AtomicDataTypeDto setInheritsFrom(String id, String parentId); + + AtomicDataTypeDto detachInheritsFrom(String id); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/IDataTypeExternalService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/IDataTypeExternalService.java new file mode 100644 index 0000000..7a7b063 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/IDataTypeExternalService.java @@ -0,0 +1,67 @@ +/* + * 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.idoris.datatypes.api; + +import edu.kit.datamanager.idoris.datatypes.dto.DataTypeDto; + +import java.util.List; +import java.util.Optional; + +/** + * External API for generic DataType operations (DTO-first). + * This logic provides operations that work with any DataType subtype. + */ +public interface IDataTypeExternalService { + + /** + * Retrieves a DataType by its ID. + * + * @param id the ID of the DataType (PID or internal ID) + * @return Optional containing the DataType DTO, or empty if not found + */ + Optional get(String id); + + /** + * Lists all DataType entities. + * + * @return list of all DataType DTOs + */ + List list(); + + /** + * Deletes a DataType by its ID. + * + * @param id the ID of the DataType to delete + */ + void delete(String id); + + /** + * Gets the inheritance hierarchy for a DataType. + * Works with both AtomicDataType and TypeProfile entities. + * + * @param id the ID of the DataType + * @return the inheritance hierarchy + */ + Object getInheritanceHierarchy(String id); + + /** + * Gets operations available for a DataType. + * + * @param id the ID of the DataType + * @return list of operations that can be executed on this DataType + */ + List getOperationsForDataType(String id); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/ITypeProfileExternalService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/ITypeProfileExternalService.java new file mode 100644 index 0000000..7bcc200 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/ITypeProfileExternalService.java @@ -0,0 +1,55 @@ +/* + * 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.idoris.datatypes.api; + +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * External API for TypeProfile operations (DTO-first). + */ +public interface ITypeProfileExternalService { + TypeProfileDto create(TypeProfileDto dto); + + TypeProfileDto update(String id, TypeProfileDto dto); + + TypeProfileDto patch(String id, TypeProfileDto dto); + + void delete(String id); + + Optional get(String id); + + List list(); + + // Relationship operations + TypeProfileDto addInheritsFrom(String profileId, Set parentIds); + + TypeProfileDto removeInheritsFrom(String profileId, Set parentIds); + + TypeProfileDto addAttributes(String profileId, Set attributeIds); + + TypeProfileDto removeAttributes(String profileId, Set attributeIds); + + // Additional query operations + Set getInheritedAttributes(String id); + + Object getInheritanceTree(String id); + + List getOperationsForTypeProfile(String id); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/package-info.java new file mode 100644 index 0000000..1513c5f --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/api/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("api") +package edu.kit.datamanager.idoris.datatypes.api; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dao/IAtomicDataTypeDao.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dao/IAtomicDataTypeDao.java new file mode 100644 index 0000000..71af18f --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dao/IAtomicDataTypeDao.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024-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.idoris.datatypes.dao; + +import edu.kit.datamanager.idoris.core.dao.IGenericRepo; +import edu.kit.datamanager.idoris.core.domain.AtomicDataType; +import org.springframework.data.neo4j.repository.query.Query; + +import java.util.Optional; + +/** + * Repository interface for AtomicDataType entities. + */ +public interface IAtomicDataTypeDao extends IGenericRepo { + /** + * Finds all AtomicDataType entities in the inheritance chain of the given AtomicDataType. + * This method first tries to find the entity by PID, and if not found, tries to find it by internal ID. + * + * @param id the ID of the AtomicDataType (either PID or internal ID) + * @return an Iterable of AtomicDataType entities in the inheritance chain + */ + default Iterable findAllInInheritanceChain(String id) { + // First try to find by PID + Optional byPid = findByPid(id); + if (byPid.isPresent()) { + return findAllInInheritanceChainByPid(id); + } + // If not found by PID, try to find by internal ID + return findAllInInheritanceChainByInternalId(id); + } + + /** + * Finds all AtomicDataType entities in the inheritance chain of the given AtomicDataType. + * + * @param pid the PID of the AtomicDataType + * @return an Iterable of AtomicDataType entities in the inheritance chain + */ + @Query("MATCH (d:AtomicDataType {pid: $pid})-[:inheritsFrom*]->(d2:AtomicDataType) RETURN d2") + Iterable findAllInInheritanceChainByPid(String pid); + + /** + * Finds all AtomicDataType entities in the inheritance chain of the given AtomicDataType. + * + * @param internalId the internal ID of the AtomicDataType + * @return an Iterable of AtomicDataType entities in the inheritance chain + */ + @Query("MATCH (d:AtomicDataType {internalId: $internalId})-[:inheritsFrom*]->(d2:AtomicDataType) RETURN d2") + Iterable findAllInInheritanceChainByInternalId(String internalId); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dao/IDataTypeDao.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dao/IDataTypeDao.java new file mode 100644 index 0000000..0640888 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dao/IDataTypeDao.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024-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.idoris.datatypes.dao; + +import edu.kit.datamanager.idoris.core.dao.IGenericRepo; +import edu.kit.datamanager.idoris.core.domain.DataType; +import edu.kit.datamanager.idoris.core.domain.Operation; +import org.springframework.data.neo4j.repository.query.Query; + +import java.util.Optional; + +/** + * Repository interface for DataType entities. + */ +public interface IDataTypeDao extends IGenericRepo { + /** + * Finds all DataType entities in the inheritance chain of the given DataType. + * This method first tries to find the entity by PID, and if not found, tries to find it by internal ID. + * + * @param id the ID of the DataType (either PID or internal ID) + * @return an Iterable of DataType entities in the inheritance chain + */ + default Iterable findAllInInheritanceChain(String id) { + Optional elem = findByPIDorInternalId(id); + return elem.map(dataType -> findAllInInheritanceChainByInternalId(dataType.getId())).orElse(null); + } + + /** + * Finds all DataType entities in the inheritance chain of the given DataType. + * + * @param internalId the internal ID of the DataType + * @return an Iterable of DataType entities in the inheritance chain + */ + @Query("MATCH (d:DataType {internalId: $internalId})-[:inheritsFrom*]->(d2:DataType) RETURN d2") + Iterable findAllInInheritanceChainByInternalId(String internalId); + + /** + * Gets operations that can be executed on a data type. + * This method finds operations that are executable on the given data type, its attributes, + * or any data type in its inheritance chain. + * This method first tries to find the entity by PID, and if not found, tries to find it by internal ID. + * + * @param id the ID of the data type (either PID or internal ID) + * @return an Iterable of Operation entities + */ + default Iterable getOperations(String id) { + // First try to find by PID + Optional byPid = findByPid(id); + if (byPid.isPresent()) { + return getOperationsByPid(id); + } + // If not found by PID, try to find by internal ID + return getOperationsByInternalId(id); + } + + /** + * Gets operations that can be executed on a data type. + * This method finds operations that are executable on the given data type, its attributes, + * or any data type in its inheritance chain. + * + * @param pid the PID of the data type + * @return an Iterable of Operation entities + */ + @Query("Match (:DataType {pid: $pid})-[:attributes|inheritsFrom*]->(:DataType)<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) return o") + Iterable getOperationsByPid(String pid); + + /** + * Gets operations that can be executed on a data type. + * This method finds operations that are executable on the given data type, its attributes, + * or any data type in its inheritance chain. + * + * @param internalId the internal ID of the data type + * @return an Iterable of Operation entities + */ + @Query("Match (:DataType {internalId: $internalId})-[:attributes|inheritsFrom*]->(:DataType)<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) return o") + Iterable getOperationsByInternalId(String internalId); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dao/ITypeProfileDao.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dao/ITypeProfileDao.java new file mode 100644 index 0000000..0dce329 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dao/ITypeProfileDao.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2024-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.idoris.datatypes.dao; + +import edu.kit.datamanager.idoris.core.dao.IGenericRepo; +import edu.kit.datamanager.idoris.core.domain.TypeProfile; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.Optional; + +@OpenAPIDefinition +public interface ITypeProfileDao extends IGenericRepo { + /** + * Finds all TypeProfile entities with their attributes in the inheritance chain of the given TypeProfile. + * This method first tries to find the entity by PID, and if not found, tries to find it by internal ID. + * + * @param id the ID of the TypeProfile (either PID or internal ID) + * @return an Iterable of TypeProfile entities with their attributes in the inheritance chain + */ + default Iterable findAllTypeProfilesWithTheirAttributesInInheritanceChain(String id) { + // First try to find by PID + Optional byPid = findByPid(id); + if (byPid.isPresent()) { + return findAllTypeProfilesWithTheirAttributesInInheritanceChainByPid(id); + } + // If not found by PID, try to find by internal ID + return findAllTypeProfilesWithTheirAttributesInInheritanceChainByInternalId(id); + } + + @Query("MATCH (pid:PIDNode {pid: $pid})-[:IDENTIFIES]->(d:TypeProfile) MATCH (d)-[i:inheritsFrom*]->(d2:TypeProfile)-[profileAttribute:attributes]->(dataType:DataType) RETURN i, d2, collect(profileAttribute), collect(dataType)") + Iterable findAllTypeProfilesWithTheirAttributesInInheritanceChainByPid(String pid); + + @Query("MATCH (d:TypeProfile {internalId: $internalId})-[i:inheritsFrom*]->(d2:TypeProfile)-[profileAttribute:attributes]->(dataType:DataType) RETURN i, d2, collect(profileAttribute), collect(dataType)") + Iterable findAllTypeProfilesWithTheirAttributesInInheritanceChainByInternalId(String internalId); + + /** + * Finds all TypeProfile entities in the inheritance chain of the given TypeProfile. + * This method first tries to find the entity by PID, and if not found, tries to find it by internal ID. + * + * @param id the ID of the TypeProfile (either PID or internal ID) + * @return an Iterable of TypeProfile entities in the inheritance chain + */ + default Iterable findAllTypeProfilesInInheritanceChain(String id) { + // First try to find by PID + Optional byPid = findByPid(id); + if (byPid.isPresent()) { + return findAllTypeProfilesInInheritanceChainByPid(id); + } + // If not found by PID, try to find by internal ID + return findAllTypeProfilesInInheritanceChainByInternalId(id); + } + + @Query("MATCH (pid:PIDNode {pid: $pid})-[:IDENTIFIES]->(d:TypeProfile) MATCH (d)-[:inheritsFrom*]->(typeProfile:TypeProfile) return typeProfile") + Iterable findAllTypeProfilesInInheritanceChainByPid(String pid); + + @Query("MATCH (d:TypeProfile {internalId: $internalId})-[:inheritsFrom*]->(typeProfile:TypeProfile) return typeProfile") + Iterable findAllTypeProfilesInInheritanceChainByInternalId(String internalId); + + // ===== Relationship operations: inheritsFrom ===== + @Query(""" + MATCH (p:TypeProfile) + WHERE p.internalId = $profileId OR EXISTS { MATCH (pid:PIDNode {pid: $profileId})-[:IDENTIFIES]->(p) } + WITH p + UNWIND $parentIds AS parentId + MATCH (parent:TypeProfile) + WHERE parent.internalId = parentId OR EXISTS { MATCH (pp:PIDNode {pid: parentId})-[:IDENTIFIES]->(parent) } + MERGE (p)-[:inheritsFrom]->(parent) + """) + void addInheritsFrom(@Param("profileId") String profileId, @Param("parentIds") Collection parentIds); + + @Query(""" + MATCH (p:TypeProfile) + WHERE p.internalId = $profileId OR EXISTS { MATCH (pid:PIDNode {pid: $profileId})-[:IDENTIFIES]->(p) } + WITH p + UNWIND $parentIds AS parentId + MATCH (parent:TypeProfile) + WHERE parent.internalId = parentId OR EXISTS { MATCH (pp:PIDNode {pid: parentId})-[:IDENTIFIES]->(parent) } + MATCH (p)-[r:inheritsFrom]->(parent) + DELETE r + """) + void removeInheritsFrom(@Param("profileId") String profileId, @Param("parentIds") Collection parentIds); + + // ===== Relationship operations: attributes ===== + @Query(""" + MATCH (p:TypeProfile) + WHERE p.internalId = $profileId OR EXISTS { MATCH (pid:PIDNode {pid: $profileId})-[:IDENTIFIES]->(p) } + WITH p + UNWIND $attributeIds AS attributeId + MATCH (a:Attribute) + WHERE a.internalId = attributeId OR EXISTS { MATCH (pp:PIDNode {pid: attributeId})-[:IDENTIFIES]->(a) } + MERGE (p)-[:attributes]->(a) + """) + void addAttributes(@Param("profileId") String profileId, @Param("attributeIds") Collection attributeIds); + + @Query(""" + MATCH (p:TypeProfile) + WHERE p.internalId = $profileId OR EXISTS { MATCH (pid:PIDNode {pid: $profileId})-[:IDENTIFIES]->(p) } + WITH p + UNWIND $attributeIds AS attributeId + MATCH (a:Attribute) + WHERE a.internalId = attributeId OR EXISTS { MATCH (pp:PIDNode {pid: attributeId})-[:IDENTIFIES]->(a) } + MATCH (p)-[r:attributes]->(a) + DELETE r + """) + void removeAttributes(@Param("profileId") String profileId, @Param("attributeIds") Collection attributeIds); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/AtomicDataTypeDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/AtomicDataTypeDto.java new file mode 100644 index 0000000..9a1fdd4 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/AtomicDataTypeDto.java @@ -0,0 +1,70 @@ +/* + * 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.idoris.datatypes.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import edu.kit.datamanager.idoris.core.domain.enums.PrimitiveDataTypes; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.Set; + +/** + * DTO for AtomicDataType exposing user-defined fields and relationship IDs. + */ +@SuppressWarnings("StringConcatToTextBlock") +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "AtomicDataType", description = "DTO representing an Atomic Data Type") +public class AtomicDataTypeDto extends DataTypeDto { + + @JsonProperty("defaultValue") + @Schema(description = "Default value") + private String defaultValue; + @JsonProperty("inheritsFromId") + @Schema(description = "ID of parent AtomicDataType this one inherits from") + private String inheritsFromId; + @JsonProperty("primitiveDataType") + @Schema(description = "Primitive data type") + private PrimitiveDataTypes primitiveDataType; + @JsonProperty("regularExpression") + @Schema(description = "Regular expression constraint") + private String regularExpression; + @JsonProperty("permittedValues") + @Schema(description = "Permitted values") + private Set permittedValues; + @JsonProperty("forbiddenValues") + @Schema(description = "Forbidden values") + private Set forbiddenValues; + @JsonProperty("minimum") + @Schema(description = "Minimum") + private Integer minimum; + @JsonProperty("maximum") + @Schema(description = "Maximum") + private Integer maximum; + + @Override + public String getType() { + return "ATOMIC"; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/DataTypeDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/DataTypeDto.java new file mode 100644 index 0000000..0bd339d --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/DataTypeDto.java @@ -0,0 +1,88 @@ +/* + * 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.idoris.datatypes.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.List; + +/** + * Base DTO for all DataType entities. + * Uses polymorphic serialization to handle different DataType subtypes. + */ +@SuppressWarnings("StringConcatToTextBlock") +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = AtomicDataTypeDto.class, name = "ATOMIC"), + @JsonSubTypes.Type(value = TypeProfileDto.class, name = "PROFILE") +}) +@Schema(description = "Base DTO for DataType entities", + discriminatorProperty = "type", + subTypes = {AtomicDataTypeDto.class, TypeProfileDto.class}) +public abstract class DataTypeDto { + + /** + * The internal identifier of the DataType. + */ + @Schema(description = "Internal identifier of the DataType", example = "dt-12345") + private String internalId; + + /** + * The name of the DataType. + */ + @JsonProperty("name") + @Schema(description = "Name of the DataType", example = "PersonType") + private String name; + + /** + * The description of the DataType. + */ + @JsonProperty("description") + @Schema(description = "Description of the DataType", example = "Data type for person entities") + private String description; + + /** + * The version of this DataType (for optimistic locking). + */ + @JsonProperty("version") + @Schema(description = "Version number for optimistic locking", example = "1") + private Long version; + + /** + * Link to the PID endpoint for this DataType. + */ + @JsonProperty("pids") + @Schema(description = "PIDs for this entity") + private List pids; + + /** + * The type discriminator for polymorphic serialization. + */ + @JsonProperty("type") + @Schema(description = "Type discriminator (ATOMIC or PROFILE)", example = "PROFILE") + public abstract String getType(); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/TypeProfileDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/TypeProfileDto.java new file mode 100644 index 0000000..6230aa8 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/TypeProfileDto.java @@ -0,0 +1,67 @@ +/* + * 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.idoris.datatypes.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import edu.kit.datamanager.idoris.core.domain.enums.CombinationOptions; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.Set; + +/** + * DTO for TypeProfile exposing user-defined fields and relationship IDs. + */ +@SuppressWarnings("StringConcatToTextBlock") +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "TypeProfile", description = "DTO representing a Type Profile") +public class TypeProfileDto extends DataTypeDto { + + @JsonProperty("defaultValue") + @Schema(description = "Default value") + private String defaultValue; + @JsonProperty("inheritsFromIds") + @Schema(description = "IDs of parent TypeProfiles this one inherits from") + private Set inheritsFromIds; + @JsonProperty("attributeIds") + @Schema(description = "IDs of Attribute nodes included in this profile") + private Set attributeIds; + @JsonProperty("permitEmbedding") + @Schema(description = "Whether embedding is permitted") + private Boolean permitEmbedding; + @JsonProperty("isAbstract") + @Schema(description = "Whether this profile is abstract") + private Boolean isAbstract; + @JsonProperty("allowAdditionalAttributes") + @Schema(description = "Whether additional attributes are allowed") + private Boolean allowAdditionalAttributes; + @JsonProperty("validationPolicy") + @Schema(description = "Validation policy for combining attributes") + private CombinationOptions validationPolicy; + + @Override + public String getType() { + return "PROFILE"; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/TypeProfileInheritance.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/TypeProfileInheritance.java new file mode 100644 index 0000000..8ccd720 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/TypeProfileInheritance.java @@ -0,0 +1,107 @@ +/* + * 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.idoris.datatypes.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Represents the inheritance tree structure of a TypeProfile. + * This class provides information about the inheritance hierarchy + * of a TypeProfile, including all parent profiles it inherits from. + */ +@SuppressWarnings("StringConcatToTextBlock") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "TypeProfile inheritance tree structure") +public class TypeProfileInheritance { + + /** + * The root TypeProfile ID (the one whose inheritance tree is being queried). + */ + @JsonProperty("rootId") + @Schema(description = "The ID of the root TypeProfile", example = "tp-12345") + private String rootId; + + /** + * The name of the root TypeProfile. + */ + @JsonProperty("rootName") + @Schema(description = "The name of the root TypeProfile", example = "PersonProfile") + private String rootName; + + /** + * List of parent TypeProfiles that this TypeProfile inherits from. + * This includes all levels of the inheritance hierarchy. + */ + @JsonProperty("inheritsFrom") + @Schema(description = "List of parent TypeProfiles in the inheritance hierarchy") + private List inheritsFrom; + + /** + * Represents a node in the inheritance tree. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "A node in the TypeProfile inheritance tree") + public static class TypeProfileNode { + + /** + * The unique identifier of the TypeProfile. + */ + @JsonProperty("id") + @Schema(description = "The unique identifier of the TypeProfile", example = "tp-67890") + private String id; + + /** + * The name of the TypeProfile. + */ + @JsonProperty("name") + @Schema(description = "The name of the TypeProfile", example = "BaseProfile") + private String name; + + /** + * The description of the TypeProfile. + */ + @JsonProperty("description") + @Schema(description = "The description of the TypeProfile", example = "Base profile for all entities") + private String description; + + /** + * The level in the inheritance hierarchy (0 = direct parent, 1 = grandparent, etc.). + */ + @JsonProperty("level") + @Schema(description = "The inheritance level (0 = direct parent)", example = "0") + private int level; + + /** + * The attributes defined directly on this TypeProfile (not inherited). + */ + @JsonProperty("directAttributes") + @Schema(description = "Attributes defined directly on this TypeProfile") + private List directAttributes; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/package-info.java new file mode 100644 index 0000000..a4a49ae --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/dto/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("dto") +package edu.kit.datamanager.idoris.datatypes.dto; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfileCreatedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfileCreatedEvent.java new file mode 100644 index 0000000..be2f30f --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfileCreatedEvent.java @@ -0,0 +1,35 @@ +/* + * 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.idoris.datatypes.events; + +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class TypeProfileCreatedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the TypeProfile") + private final String id; + private final TypeProfileDto payload; + + public TypeProfileCreatedEvent(String id, TypeProfileDto payload) { + this.id = id; + this.payload = payload; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfileDeletedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfileDeletedEvent.java new file mode 100644 index 0000000..6349bf4 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfileDeletedEvent.java @@ -0,0 +1,35 @@ +/* + * 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.idoris.datatypes.events; + +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class TypeProfileDeletedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the TypeProfile") + private final String id; + private final TypeProfileDto payload; + + public TypeProfileDeletedEvent(String id, TypeProfileDto payload) { + this.id = id; + this.payload = payload; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfilePatchedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfilePatchedEvent.java new file mode 100644 index 0000000..fb86aa2 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfilePatchedEvent.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.idoris.datatypes.events; + +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class TypeProfilePatchedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the TypeProfile") + private final String id; + private final Long previousVersion; + private final TypeProfileDto payload; + + public TypeProfilePatchedEvent(String id, Long previousVersion, TypeProfileDto payload) { + this.id = id; + this.previousVersion = previousVersion; + this.payload = payload; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfileUpdatedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfileUpdatedEvent.java new file mode 100644 index 0000000..2f78af5 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/TypeProfileUpdatedEvent.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.idoris.datatypes.events; + +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class TypeProfileUpdatedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the TypeProfile") + private final String id; + private final Long previousVersion; + private final TypeProfileDto payload; + + public TypeProfileUpdatedEvent(String id, Long previousVersion, TypeProfileDto payload) { + this.id = id; + this.previousVersion = previousVersion; + this.payload = payload; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/package-info.java new file mode 100644 index 0000000..14eb460 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/events/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("events") +package edu.kit.datamanager.idoris.datatypes.events; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/mappers/AtomicDataTypeMapper.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/mappers/AtomicDataTypeMapper.java new file mode 100644 index 0000000..3545db8 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/mappers/AtomicDataTypeMapper.java @@ -0,0 +1,90 @@ +/* + * 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.idoris.datatypes.mappers; + +import edu.kit.datamanager.idoris.core.domain.AtomicDataType; +import edu.kit.datamanager.idoris.core.domain.valueObjects.Name; +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; +import edu.kit.datamanager.idoris.datatypes.dto.AtomicDataTypeDto; +import edu.kit.datamanager.idoris.pids.api.IInternalPIDService; +import io.micrometer.observation.annotation.Observed; +import org.springframework.stereotype.Component; + +/** + * Mapper for AtomicDataType entity and DTO. + * - Convert entity -> DTO exposing user fields and relationship IDs + * - Convert DTO -> entity (scalar fields only) + * - Apply partial updates (patch semantics) for scalar fields + */ +@Observed(contextualName = "atomicDataTypeMapper") +@Component +public class AtomicDataTypeMapper { + private final IInternalPIDService internalPIDService; + + public AtomicDataTypeMapper(IInternalPIDService internalPIDService) { + this.internalPIDService = internalPIDService; + } + + public AtomicDataTypeDto toDto(AtomicDataType entity) { + if (entity == null) return null; + String inheritsFromId = entity.getInheritsFrom() != null ? entity.getInheritsFrom().getId() : null; + return AtomicDataTypeDto.builder() + .internalId(entity.getId()) + .name(entity.getName().toString()) + .description(entity.getDescription().toString()) + .defaultValue(entity.getDefaultValue()) + .inheritsFromId(inheritsFromId) + .primitiveDataType(entity.getPrimitiveDataType()) + .regularExpression(entity.getRegularExpression()) + .permittedValues(entity.getPermittedValues()) + .forbiddenValues(entity.getForbiddenValues()) + .minimum(entity.getMinimum()) + .maximum(entity.getMaximum()) + .pids(internalPIDService.getPIDAssociatedWithInternalID(entity.getId()).stream().map(PID::toString).toList()) + .build(); + } + + public AtomicDataType toEntity(AtomicDataTypeDto dto) { + if (dto == null) return null; + AtomicDataType entity = new AtomicDataType(); + entity.setName(new Name(dto.getName())); + entity.setDescription(new Name(dto.getDescription())); + entity.setDefaultValue(dto.getDefaultValue()); + entity.setPrimitiveDataType(dto.getPrimitiveDataType()); + entity.setRegularExpression(dto.getRegularExpression()); + entity.setPermittedValues(dto.getPermittedValues()); + entity.setForbiddenValues(dto.getForbiddenValues()); + entity.setMinimum(dto.getMinimum()); + entity.setMaximum(dto.getMaximum()); + // Relationship inheritsFrom is set via logic/DAO using ID + return entity; + } + + public AtomicDataType applyPatch(AtomicDataTypeDto dto, AtomicDataType entity) { + if (dto == null || entity == null) return entity; + if (dto.getName() != null) entity.setName(new Name(dto.getName())); + if (dto.getDescription() != null) entity.setDescription(new Name(dto.getDescription())); + if (dto.getDefaultValue() != null) entity.setDefaultValue(dto.getDefaultValue()); + if (dto.getPrimitiveDataType() != null) entity.setPrimitiveDataType(dto.getPrimitiveDataType()); + if (dto.getRegularExpression() != null) entity.setRegularExpression(dto.getRegularExpression()); + if (dto.getPermittedValues() != null) entity.setPermittedValues(dto.getPermittedValues()); + if (dto.getForbiddenValues() != null) entity.setForbiddenValues(dto.getForbiddenValues()); + if (dto.getMinimum() != null) entity.setMinimum(dto.getMinimum()); + if (dto.getMaximum() != null) entity.setMaximum(dto.getMaximum()); + // Relationship ID handled elsewhere + return entity; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/mappers/TypeProfileMapper.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/mappers/TypeProfileMapper.java new file mode 100644 index 0000000..84fe26b --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/mappers/TypeProfileMapper.java @@ -0,0 +1,112 @@ +/* + * 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.idoris.datatypes.mappers; + +import edu.kit.datamanager.idoris.core.domain.Attribute; +import edu.kit.datamanager.idoris.core.domain.TypeProfile; +import edu.kit.datamanager.idoris.core.domain.valueObjects.Name; +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import edu.kit.datamanager.idoris.pids.api.IInternalPIDService; +import io.micrometer.observation.annotation.Observed; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Mapper for TypeProfile entity and DTO. + * - Convert entity -> DTO exposing user fields and relationship IDs + * - Convert DTO -> entity (scalar fields only) + * - Apply partial updates (patch semantics) for scalar fields + */ +@Observed(contextualName = "typeProfileMapper") +@Component +public class TypeProfileMapper { + private final IInternalPIDService internalPIDService; + + public TypeProfileMapper(IInternalPIDService internalPIDService) { + this.internalPIDService = internalPIDService; + } + + public TypeProfileDto toDto(TypeProfile entity) { + if (entity == null) return null; + Set inheritsFromIds = toIds(entity.getInheritsFrom()); + Set attributeIds = toIdsAttr(entity.getAttributes()); + return TypeProfileDto.builder() + .internalId(entity.getId()) + .name(entity.getName().toString()) + .description(entity.getDescription().toString()) + .defaultValue(entity.getDefaultValue()) + .inheritsFromIds(inheritsFromIds) + .attributeIds(attributeIds) + .permitEmbedding(entity.isPermitEmbedding()) + .isAbstract(entity.isAbstract()) + .allowAdditionalAttributes(entity.isAllowAdditionalAttributes()) + .validationPolicy(entity.getValidationPolicy()) + .pids(internalPIDService.getPIDAssociatedWithInternalID(entity.getId()).stream().map(PID::toString).toList()) + .build(); + } + + private Set toIds(Set profiles) { + if (profiles == null) return Collections.emptySet(); + return profiles.stream() + .filter(Objects::nonNull) + .map(TypeProfile::getId) + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableSet()); + } + + private Set toIdsAttr(Set attributes) { + if (attributes == null) return Collections.emptySet(); + return attributes.stream() + .filter(Objects::nonNull) + .map(Attribute::getId) + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableSet()); + } + + public TypeProfile toEntity(TypeProfileDto dto) { + if (dto == null) return null; + TypeProfile entity = new TypeProfile(); + entity.setName(new Name(dto.getName())); + entity.setDescription(new Name(dto.getDescription())); + entity.setDefaultValue(dto.getDefaultValue()); + if (dto.getPermitEmbedding() != null) entity.setPermitEmbedding(dto.getPermitEmbedding()); + if (dto.getIsAbstract() != null) entity.setAbstract(dto.getIsAbstract()); + if (dto.getAllowAdditionalAttributes() != null) + entity.setAllowAdditionalAttributes(dto.getAllowAdditionalAttributes()); + if (dto.getValidationPolicy() != null) entity.setValidationPolicy(dto.getValidationPolicy()); + // Relationships (inheritsFrom, attributes) are linked via logic/DAO using IDs + return entity; + } + + public TypeProfile applyPatch(TypeProfileDto dto, TypeProfile entity) { + if (dto == null || entity == null) return entity; + if (dto.getName() != null) entity.setName(new Name(dto.getName())); + if (dto.getDescription() != null) entity.setDescription(new Name(dto.getDescription())); + if (dto.getDefaultValue() != null) entity.setDefaultValue(dto.getDefaultValue()); + if (dto.getPermitEmbedding() != null) entity.setPermitEmbedding(dto.getPermitEmbedding()); + if (dto.getIsAbstract() != null) entity.setAbstract(dto.getIsAbstract()); + if (dto.getAllowAdditionalAttributes() != null) + entity.setAllowAdditionalAttributes(dto.getAllowAdditionalAttributes()); + if (dto.getValidationPolicy() != null) entity.setValidationPolicy(dto.getValidationPolicy()); + // Relationship IDs handled elsewhere + return entity; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/package-info.java new file mode 100644 index 0000000..2d286ad --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/package-info.java @@ -0,0 +1,28 @@ +/* + * 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. + */ + +/** + * DataTypes module for IDORIS. + * This module contains entity definitions, domain services, and business logic related data types. + * It is responsible for managing atomic data types and their relationships. + * + *

    The DataTypes module depends on the core module for base abstractions and interfaces.

    + */ +@org.springframework.modulith.ApplicationModule( + displayName = "IDORIS DataTypes", + allowedDependencies = {"core", "attributes", "rules", "operations", "operations :: operations.services.api", "operations :: dto", "pids :: api"} +) +package edu.kit.datamanager.idoris.datatypes; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/rules/AcyclicityValidator.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/rules/AcyclicityValidator.java new file mode 100644 index 0000000..174c032 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/rules/AcyclicityValidator.java @@ -0,0 +1,155 @@ +/* + * 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.idoris.datatypes.rules; + +import edu.kit.datamanager.idoris.core.domain.AtomicDataType; +import edu.kit.datamanager.idoris.core.domain.DataType; +import edu.kit.datamanager.idoris.core.domain.TypeProfile; +import edu.kit.datamanager.idoris.rules.logic.Rule; +import edu.kit.datamanager.idoris.rules.validation.SyntaxValidator; +import edu.kit.datamanager.idoris.rules.validation.ValidationResult; +import edu.kit.datamanager.idoris.rules.validation.ValidationVisitor; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.neo4j.core.Neo4jClient; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Slf4j +@Component +@Observed(contextualName = "acyclicityValidator") +@Rule( + appliesTo = { + AtomicDataType.class, + TypeProfile.class + }, + name = "AcyclicityValidationRule", + description = "Validates that entities do not form cycles in their inheritance structure", + tasks = {edu.kit.datamanager.idoris.rules.logic.RuleTask.VALIDATE}, + dependsOn = {SyntaxValidator.class} +) +public class AcyclicityValidator extends ValidationVisitor { + private final Neo4jClient neo4jClient; + + public AcyclicityValidator(Neo4jClient neo4jClient) { + this.neo4jClient = neo4jClient; + } + + @WithSpan(kind = SpanKind.INTERNAL) + public ValidationResult visit(AtomicDataType atomicDataType, Object... args) { + return doesNotInheritItself(atomicDataType); + } + + /** + * Validates that a DataType (TypeProfile or AtomicDataType) does not inherit from itself, preventing circular inheritance. + * + * @param dataType The DataType to validate + * @return ValidationResult containing any validation errors + */ + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "rules.acyclicityValidator.doesNotInheritItself", description = "Time to check self-inheritance acyclicity", histogram = true) + @Counted(value = "rules.acyclicityValidator.doesNotInheritItself.count", description = "Number of self-inheritance acyclicity checks") + private ValidationResult doesNotInheritItself(DataType dataType) { + String query = "MATCH path = (n:DataType {id: $nodeID})-[:inheritsFrom*1..]->(n) RETURN path"; + + // Query the path from the Neo4j database + var path = neo4jClient.query(query) + .bind(dataType.getId()).to("nodeID") + .fetch() + .all(); + + if (!path.isEmpty()) { + return ValidationResult.error("Circular inheritance detected", rule, Map.of("element", dataType, "path", path)); + } else { + return ValidationResult.ok(); + } + } + + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "rules.acyclicityValidator.visitTypeProfile", description = "Time to check acyclicity for TypeProfile", histogram = true) + @Counted(value = "rules.acyclicityValidator.visitTypeProfile.count", description = "Number of TypeProfile acyclicity validations") + public ValidationResult visit(TypeProfile profile, Object... args) { + return ValidationResult.combine( + doesNotInheritItself(profile), + doesNotUseItselfAsAttribute(profile) + ); + } + + /** + * Validates that a TypeProfile does not use itself as an attribute type, either directly or through overrides. + * + * @param profile The TypeProfile to validate + * @return ValidationResult containing any validation errors + */ + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "rules.acyclicityValidator.doesNotUseItselfAsAttribute", description = "Time to check for self-reference as attribute", histogram = true) + @Counted(value = "rules.acyclicityValidator.doesNotUseItselfAsAttribute.count", description = "Number of self-reference attribute checks") + private ValidationResult doesNotUseItselfAsAttribute(TypeProfile profile) { + // Optimized single unified query to check for all types of self-reference cycles + // Using MATCH...WHERE pattern for better readability and performance + String query = "// Direct cycle check" + + "MATCH path = (n:TypeProfile)-[:attributes]->(a:Attribute)-[:dataType]->(dt:DataType)" + + "WHERE n.id = $nodeID AND dt.id = $nodeID " + + "RETURN path, a.id AS attributeId, 'direct' AS cycleType, NULL AS baseAttributeId" + + "UNION" + + "// Attribute override cycle check" + + "MATCH path = (n:TypeProfile)-[:attributes]->(a:Attribute)-[:override*1..]->(b:Attribute)-[:dataType]->(dt:DataType)" + + "WHERE n.id = $nodeID AND dt.id = $nodeID " + + "RETURN path, a.id AS attributeId, 'override' AS cycleType, b.id AS baseAttributeId" + + "UNION" + + "// Data type inheritance cycle check" + + "MATCH path = (n:TypeProfile)-[:attributes]->(a:Attribute)-[:dataType]->(dt1:DataType)-[:inheritsFrom*1..]->(dt2:DataType)" + + "WHERE n.id = $nodeID AND dt2.id = $nodeID " + + "RETURN path, a.id AS attributeId, 'inheritance' AS cycleType, NULL AS baseAttributeId" + + "LIMIT 1"; + + // Execute the query - use fetchAs(Map.class) to get proper type-safe access to results + var result = neo4jClient.query(query) + .bind(profile.getId()).to("nodeID") + .fetchAs(java.util.Map.class) + .one(); + + if (result.isPresent()) { + var map = result.get(); + String cycleType = String.valueOf(map.get("cycleType")); + + String errorMessage = switch (cycleType) { + case "direct" -> "TypeProfile directly uses itself as an attribute type."; + case "override" -> { + String baseAttributeId = map.get("baseAttributeId") != null ? + String.valueOf(map.get("baseAttributeId")) : "Unknown"; + yield "TypeProfile indirectly uses itself through attribute override. Base attribute ID: " + baseAttributeId; + } + case "inheritance" -> "TypeProfile indirectly uses itself through data type inheritance."; + default -> "TypeProfile has a cyclic reference in its attributes."; + }; + + return ValidationResult.error("Illegal path: " + errorMessage, rule, Map.of( + "element", profile, + "path", map.get("path"), + "cycleType", cycleType + )); + } + + return ValidationResult.ok(); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/AtomicDataTypeService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/AtomicDataTypeService.java new file mode 100644 index 0000000..66c72a8 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/AtomicDataTypeService.java @@ -0,0 +1,565 @@ +/* + * 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.idoris.datatypes.services; + +// ... existing code ... + +import edu.kit.datamanager.idoris.core.configuration.ApplicationProperties; +import edu.kit.datamanager.idoris.core.domain.AtomicDataType; +import edu.kit.datamanager.idoris.core.events.EventPublisherService; +import edu.kit.datamanager.idoris.core.exceptions.ValidationException; +import edu.kit.datamanager.idoris.datatypes.api.IAtomicDataTypeExternalService; +import edu.kit.datamanager.idoris.datatypes.dao.IAtomicDataTypeDao; +import edu.kit.datamanager.idoris.datatypes.dto.AtomicDataTypeDto; +import edu.kit.datamanager.idoris.datatypes.mappers.AtomicDataTypeMapper; +import edu.kit.datamanager.idoris.rules.logic.RuleService; +import edu.kit.datamanager.idoris.rules.logic.RuleTask; +import edu.kit.datamanager.idoris.rules.validation.ValidationResult; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Service for managing AtomicDataType entities. + * This service provides methods for creating, updating, and retrieving AtomicDataType entities using DTOs exclusively. + * It publishes domain events when entities are created, updated, or deleted. + */ +@Service +@Slf4j +@Observed(contextualName = "atomicDataTypeService") +public class AtomicDataTypeService implements IAtomicDataTypeExternalService { + private final IAtomicDataTypeDao atomicDataTypeDao; + private final EventPublisherService eventPublisher; + private final AtomicDataTypeMapper mapper; + private final RuleService ruleService; + private final ApplicationProperties applicationProperties; + + /** + * Creates a new AtomicDataTypeService with the given dependencies. + * + * @param atomicDataTypeDao the AtomicDataType repository + * @param eventPublisher the event publisher service + * @param mapper the AtomicDataType mapper + * @param ruleService the rule service for validation + * @param applicationProperties the application properties + */ + public AtomicDataTypeService(IAtomicDataTypeDao atomicDataTypeDao, EventPublisherService eventPublisher, AtomicDataTypeMapper mapper, RuleService ruleService, ApplicationProperties applicationProperties) { + this.atomicDataTypeDao = atomicDataTypeDao; + this.eventPublisher = eventPublisher; + this.mapper = mapper; + this.ruleService = ruleService; + this.applicationProperties = applicationProperties; + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "atomicDataTypeService.create", description = "Time taken to create an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeService.create.count", description = "Number of atomic data type creations") + public AtomicDataTypeDto create(@Valid AtomicDataTypeDto dto) { + log.debug("Creating AtomicDataType DTO: {}", dto.getName()); + + AtomicDataType entity = mapper.toEntity(dto); + entity.setInternalId(null); + entity.setVersion(null); + + // Validate BEFORE saving + ValidationResult validationResult = ruleService.executeRules( + RuleTask.VALIDATE, + entity, + ValidationResult::new + ); + log.debug("Validation result for AtomicDataType {}: {}", entity, validationResult); + + // Check if validation failed based on validation policy + if (hasValidationErrors(validationResult)) { + throw new ValidationException("Entity validation failed", validationResult); + } + + AtomicDataType saved = atomicDataTypeDao.save(entity); + eventPublisher.publishEntityCreated(saved); + log.info("Created AtomicDataType with PID: {}", saved.getId()); + return mapper.toDto(saved); + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "atomicDataTypeService.update", description = "Time taken to update an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeService.update.count", description = "Number of atomic data type updates") + public AtomicDataTypeDto update(String id, AtomicDataTypeDto dto) { + log.debug("Updating AtomicDataType with ID: {}", id); + + AtomicDataType existing = atomicDataTypeDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("AtomicDataType not found with ID: " + id)); + + Long previousVersion = existing.getVersion(); + + // Apply the DTO to the existing entity + mapper.applyPatch(dto, existing); + + // Validate BEFORE saving + ValidationResult validationResult = ruleService.executeRules( + RuleTask.VALIDATE, + existing, + ValidationResult::new + ); + log.debug("Validation result for AtomicDataType {}: {}", existing, validationResult); + + // Check if validation failed + if (hasValidationErrors(validationResult)) { + throw new ValidationException("Entity validation failed", validationResult); + } + + AtomicDataType saved = atomicDataTypeDao.save(existing); + eventPublisher.publishEntityUpdated(saved, previousVersion); + log.info("Updated AtomicDataType with PID: {}", saved.getId()); + return mapper.toDto(saved); + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "atomicDataTypeService.patch", description = "Time taken to patch an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeService.patch.count", description = "Number of atomic data type patches") + public AtomicDataTypeDto patch(String id, AtomicDataTypeDto dto) { + log.debug("Patching AtomicDataType with ID: {}", id); + + AtomicDataType existing = atomicDataTypeDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("AtomicDataType not found with ID: " + id)); + + Long previousVersion = existing.getVersion(); + + // Apply the patch + mapper.applyPatch(dto, existing); + + // Validate BEFORE saving + ValidationResult validationResult = ruleService.executeRules( + RuleTask.VALIDATE, + existing, + ValidationResult::new + ); + + // Check if validation failed + if (hasValidationErrors(validationResult)) { + throw new ValidationException("Entity validation failed", validationResult); + } + + AtomicDataType saved = atomicDataTypeDao.save(existing); + eventPublisher.publishEntityPatched(saved, previousVersion); + log.info("Patched AtomicDataType with PID: {}", saved.getId()); + return mapper.toDto(saved); + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "atomicDataTypeService.delete", description = "Time taken to delete an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeService.delete.count", description = "Number of atomic data type deletions") + public void delete(@SpanAttribute("atomicDataType.id") String id) { + log.debug("Deleting AtomicDataType with ID: {}", id); + + AtomicDataType atomicDataType = atomicDataTypeDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("AtomicDataType not found with ID: " + id)); + + atomicDataTypeDao.delete(atomicDataType); + eventPublisher.publishEntityDeleted(atomicDataType); + log.info("Deleted AtomicDataType with ID: {}", id); + } + + @Override + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "atomicDataTypeService.get", description = "Time taken to get an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeService.get.count", description = "Number of atomic data type retrievals") + public Optional get(@SpanAttribute("atomicDataType.id") String id) { + log.debug("Retrieving AtomicDataType with ID: {}", id); + return atomicDataTypeDao.findById(id).map(mapper::toDto); + } + + @Override + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "atomicDataTypeService.list", description = "Time taken to get all atomic data types", histogram = true) + @Counted(value = "atomicDataTypeService.list.count", description = "Number of get all atomic data types requests") + public List list() { + log.debug("Retrieving all AtomicDataTypes"); + return atomicDataTypeDao.findAll().stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "atomicDataTypeService.setInheritsFrom", description = "Time taken to set inheritance for an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeService.setInheritsFrom.count", description = "Number of set inheritance operations") + public AtomicDataTypeDto setInheritsFrom(String id, String parentId) { + log.debug("Setting inheritance for AtomicDataType with ID: {} to parent: {}", id, parentId); + + AtomicDataType existing = atomicDataTypeDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("AtomicDataType not found with ID: " + id)); + + // Find the parent AtomicDataType + AtomicDataType parent = atomicDataTypeDao.findById(parentId) + .orElseThrow(() -> new IllegalArgumentException("Parent AtomicDataType not found with ID: " + parentId)); + + Long previousVersion = existing.getVersion(); + + // Set the inheritance relationship + existing.setInheritsFrom(parent); + + // Validate BEFORE saving + ValidationResult validationResult = ruleService.executeRules( + RuleTask.VALIDATE, + existing, + ValidationResult::new + ); + + // Check if validation failed + if (hasValidationErrors(validationResult)) { + throw new ValidationException("Entity validation failed", validationResult); + } + + AtomicDataType saved = atomicDataTypeDao.save(existing); + eventPublisher.publishEntityUpdated(saved, previousVersion); + + log.info("Set inheritance for AtomicDataType {} to parent {}", saved.getId(), parentId); + return mapper.toDto(saved); + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "atomicDataTypeService.detachInheritsFrom", description = "Time taken to detach inheritance for an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeService.detachInheritsFrom.count", description = "Number of detach inheritance operations") + public AtomicDataTypeDto detachInheritsFrom(String id) { + log.debug("Detaching inheritance for AtomicDataType with ID: {}", id); + + AtomicDataType existing = atomicDataTypeDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("AtomicDataType not found with ID: " + id)); + + Long previousVersion = existing.getVersion(); + + // Remove the inheritance relationship + existing.setInheritsFrom(null); + + // Validate BEFORE saving + ValidationResult validationResult = ruleService.executeRules( + RuleTask.VALIDATE, + existing, + ValidationResult::new + ); + + // Check if validation failed + if (hasValidationErrors(validationResult)) { + throw new ValidationException("Entity validation failed", validationResult); + } + + AtomicDataType saved = atomicDataTypeDao.save(existing); + eventPublisher.publishEntityUpdated(saved, previousVersion); + + log.info("Detached inheritance for AtomicDataType {}", saved.getId()); + return mapper.toDto(saved); + } + + private boolean hasValidationErrors(ValidationResult validationResult) { + return validationResult.getOutputMessages() + .entrySet() + .stream() + .anyMatch(entry -> entry.getKey().isHigherOrEqualTo(applicationProperties.getValidationLevel()) + && !entry.getValue().isEmpty()); + } + +// // Legacy methods for backward compatibility - delegate to DTO methods +// +// /** +// * @deprecated Use create(AtomicDataTypeDto) instead +// */ +// @Deprecated +// @Transactional +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.createAtomicDataType", description = "Time taken to create an atomic data type", histogram = true) +// @Counted(value = "atomicDataTypeService.createAtomicDataType.count", description = "Number of atomic data type creations") +// public AtomicDataType createAtomicDataType(AtomicDataType atomicDataType) { +// AtomicDataTypeDto dto = mapper.toDto(atomicDataType); +// AtomicDataTypeDto created = create(dto); +// return mapper.toEntity(created); +// } +// +// /** +// * @deprecated Use update(String, AtomicDataTypeDto) instead +// */ +// @Deprecated +// @Transactional +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.updateAtomicDataType", description = "Time taken to update an atomic data type", histogram = true) +// @Counted(value = "atomicDataTypeService.updateAtomicDataType.count", description = "Number of atomic data type updates") +// public AtomicDataType updateAtomicDataType(AtomicDataType atomicDataType) { +// AtomicDataTypeDto dto = mapper.toDto(atomicDataType); +// AtomicDataTypeDto updated = update(atomicDataType.getId(), dto); +// return mapper.toEntity(updated); +// } +// +// /** +// * @deprecated Use delete(String) instead +// */ +// @Deprecated +// public void deleteAtomicDataType(String id) { +// delete(id); +// } +// +// /** +// * @deprecated Use get(String) instead +// */ +// @Deprecated +// @Transactional(readOnly = true) +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.getAtomicDataType", description = "Time taken to get an atomic data type", histogram = true) +// @Counted(value = "atomicDataTypeService.getAtomicDataType.count", description = "Number of atomic data type retrievals") +// public Optional getAtomicDataType(@SpanAttribute("atomicDataType.id") String id) { +// return get(id).map(mapper::toEntity); +// } +// +// /** +// * @deprecated Use list() instead +// */ +// @Deprecated +// @Transactional(readOnly = true) +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.getAllAtomicDataTypes", description = "Time taken to get all atomic data types", histogram = true) +// @Counted(value = "atomicDataTypeService.getAllAtomicDataTypes.count", description = "Number of get all atomic data types requests") +// public List getAllAtomicDataTypes() { +// return list().stream() +// .map(mapper::toEntity) +// .collect(Collectors.toList()); +// } +// +// /** +// * @deprecated Use patch(String, AtomicDataTypeDto) instead +// */ +// @Deprecated +// @Transactional +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.patchAtomicDataType", description = "Time taken to patch an atomic data type", histogram = true) +// @Counted(value = "atomicDataTypeService.patchAtomicDataType.count", description = "Number of atomic data type patches") +// public AtomicDataType patchAtomicDataType(@SpanAttribute("atomicDataType.id") String id, AtomicDataType atomicDataTypePatch) { +// AtomicDataTypeDto dto = mapper.toDto(atomicDataTypePatch); +// AtomicDataTypeDto patched = patch(id, dto); +// return mapper.toEntity(patched); +// } +} +// +/// ** +// * Service for managing AtomicDataType entities. +// * This logic provides methods for creating, updating, and retrieving AtomicDataType entities. +// * It publishes domain events when entities are created, updated, or deleted. +// */ +//@Service +//@Slf4j +//@Observed(contextualName = "atomicDataTypeService") +//public class AtomicDataTypeService { +// private final IAtomicDataTypeDao atomicDataTypeDao; +// private final EventPublisherService eventPublisher; +// +// /** +// * Creates a new AtomicDataTypeService with the given dependencies. +// * +// * @param atomicDataTypeDao the AtomicDataType repository +// * @param eventPublisher the event publisher logic +// */ +// public AtomicDataTypeService(IAtomicDataTypeDao atomicDataTypeDao, EventPublisherService eventPublisher) { +// this.atomicDataTypeDao = atomicDataTypeDao; +// this.eventPublisher = eventPublisher; +// } +// +// /** +// * Creates a new AtomicDataType entity. +// * +// * @param atomicDataType the AtomicDataType entity to create +// * @return the created AtomicDataType entity +// */ +// @Transactional +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.createAtomicDataType", description = "Time taken to create an atomic data type", histogram = true) +// @Counted(value = "atomicDataTypeService.createAtomicDataType.count", description = "Number of atomic data type creations") +// public AtomicDataType createAtomicDataType(AtomicDataType atomicDataType) { +// log.debug("Creating AtomicDataType: {}", atomicDataType); +// atomicDataType.setInternalId(null); +// atomicDataType.setVersion(null); +// AtomicDataType saved = atomicDataTypeDao.save(atomicDataType); +// eventPublisher.publishEntityCreated(saved); +// log.info("Created AtomicDataType with PID: {}", saved.getId()); +// return saved; +// } +// +// /** +// * Updates an existing AtomicDataType entity. +// * +// * @param atomicDataType the AtomicDataType entity to update +// * @return the updated AtomicDataType entity +// * @throws IllegalArgumentException if the AtomicDataType does not exist +// */ +// @Transactional +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.updateAtomicDataType", description = "Time taken to update an atomic data type", histogram = true) +// @Counted(value = "atomicDataTypeService.updateAtomicDataType.count", description = "Number of atomic data type updates") +// public AtomicDataType updateAtomicDataType(AtomicDataType atomicDataType) { +// log.debug("Updating AtomicDataType: {}", atomicDataType); +// +// if (atomicDataType.getId() == null || atomicDataType.getId().isEmpty()) { +// throw new IllegalArgumentException("AtomicDataType must have a PID to be updated"); +// } +// +// // Get the current version before updating +// AtomicDataType existing = atomicDataTypeDao.findById(atomicDataType.getId()) +// .orElseThrow(() -> new IllegalArgumentException("AtomicDataType not found with PID: " + atomicDataType.getId())); +// +// Long previousVersion = existing.getVersion(); +// +// AtomicDataType saved = atomicDataTypeDao.save(atomicDataType); +// eventPublisher.publishEntityUpdated(saved, previousVersion); +// log.info("Updated AtomicDataType with PID: {}", saved.getId()); +// return saved; +// } +// +// /** +// * Deletes an AtomicDataType entity. +// * +// * @param id the PID or internal ID of the AtomicDataType to delete +// * @throws IllegalArgumentException if the AtomicDataType does not exist +// */ +// @Transactional +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.deleteAtomicDataType", description = "Time taken to delete an atomic data type", histogram = true) +// @Counted(value = "atomicDataTypeService.deleteAtomicDataType.count", description = "Number of atomic data type deletions") +// public void deleteAtomicDataType(@SpanAttribute("atomicDataType.id") String id) { +// log.debug("Deleting AtomicDataType with ID: {}", id); +// +// AtomicDataType atomicDataType = atomicDataTypeDao.findById(id) +// .orElseThrow(() -> new IllegalArgumentException("AtomicDataType not found with ID: " + id)); +// +// atomicDataTypeDao.delete(atomicDataType); +// eventPublisher.publishEntityDeleted(atomicDataType); +// log.info("Deleted AtomicDataType with ID: {}", id); +// } +// +// /** +// * Retrieves an AtomicDataType entity by its PID or internal ID. +// * +// * @param id the PID or internal ID of the AtomicDataType to retrieve +// * @return an Optional containing the AtomicDataType, or empty if not found +// */ +// @Transactional(readOnly = true) +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.getAtomicDataType", description = "Time taken to get an atomic data type", histogram = true) +// @Counted(value = "atomicDataTypeService.getAtomicDataType.count", description = "Number of atomic data type retrievals") +// public Optional getAtomicDataType(@SpanAttribute("atomicDataType.id") String id) { +// log.debug("Retrieving AtomicDataType with ID: {}", id); +// return atomicDataTypeDao.findById(id); +// } +// +// /** +// * Retrieves all AtomicDataType entities. +// * +// * @return a list of all AtomicDataType entities +// */ +// @Transactional(readOnly = true) +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.getAllAtomicDataTypes", description = "Time taken to get all atomic data types", histogram = true) +// @Counted(value = "atomicDataTypeService.getAllAtomicDataTypes.count", description = "Number of get all atomic data types requests") +// public List getAllAtomicDataTypes() { +// log.debug("Retrieving all AtomicDataTypes"); +// return atomicDataTypeDao.findAll(); +// } +// +// /** +// * Partially updates an existing AtomicDataType entity. +// * +// * @param id the PID or internal ID of the AtomicDataType to patch +// * @param atomicDataTypePatch the partial AtomicDataType entity with fields to update +// * @return the patched AtomicDataType entity +// * @throws IllegalArgumentException if the AtomicDataType does not exist +// */ +// @Transactional +// @WithSpan(kind = SpanKind.INTERNAL) +// @Timed(value = "atomicDataTypeService.patchAtomicDataType", description = "Time taken to patch an atomic data type", histogram = true) +// @Counted(value = "atomicDataTypeService.patchAtomicDataType.count", description = "Number of atomic data type patches") +// public AtomicDataType patchAtomicDataType(@SpanAttribute("atomicDataType.id") String id, AtomicDataType atomicDataTypePatch) { +// log.debug("Patching AtomicDataType with ID: {}, patch: {}", id, atomicDataTypePatch); +// if (id == null || id.isEmpty()) { +// throw new IllegalArgumentException("AtomicDataType ID cannot be null or empty"); +// } +// +// // Get the current entity +// AtomicDataType existing = atomicDataTypeDao.findById(id) +// .orElseThrow(() -> new IllegalArgumentException("AtomicDataType not found with ID: " + id)); +// Long previousVersion = existing.getVersion(); +// +// // Apply non-null fields from the patch to the existing entity +// if (atomicDataTypePatch.getName() != null) { +// existing.setName(atomicDataTypePatch.getName()); +// } +// if (atomicDataTypePatch.getDescription() != null) { +// existing.setDescription(atomicDataTypePatch.getDescription()); +// } +// if (atomicDataTypePatch.getDefaultValue() != null) { +// existing.setDefaultValue(atomicDataTypePatch.getDefaultValue()); +// } +// if (atomicDataTypePatch.getPrimitiveDataType() != null) { +// existing.setPrimitiveDataType(atomicDataTypePatch.getPrimitiveDataType()); +// } +// if (atomicDataTypePatch.getRegularExpression() != null) { +// existing.setRegularExpression(atomicDataTypePatch.getRegularExpression()); +// } +// if (atomicDataTypePatch.getPermittedValues() != null) { +// existing.setPermittedValues(atomicDataTypePatch.getPermittedValues()); +// } +// if (atomicDataTypePatch.getForbiddenValues() != null) { +// existing.setForbiddenValues(atomicDataTypePatch.getForbiddenValues()); +// } +// if (atomicDataTypePatch.getMinimum() != null) { +// existing.setMinimum(atomicDataTypePatch.getMinimum()); +// } +// if (atomicDataTypePatch.getMaximum() != null) { +// existing.setMaximum(atomicDataTypePatch.getMaximum()); +// } +// if (atomicDataTypePatch.getInheritsFrom() != null) { +// existing.setInheritsFrom(atomicDataTypePatch.getInheritsFrom()); +// } +// +// // Save the updated entity +// AtomicDataType saved = atomicDataTypeDao.save(existing); +// +// // Publish the patched event +// eventPublisher.publishEntityPatched(saved, previousVersion); +// +// log.info("Patched AtomicDataType with PID: {}", saved.getId()); +// return saved; +// } +//} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/DataTypeDtoService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/DataTypeDtoService.java new file mode 100644 index 0000000..17ceddc --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/DataTypeDtoService.java @@ -0,0 +1,210 @@ +/* + * 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.idoris.datatypes.services; + +import edu.kit.datamanager.idoris.core.domain.AtomicDataType; +import edu.kit.datamanager.idoris.core.domain.TypeProfile; +import edu.kit.datamanager.idoris.datatypes.api.IDataTypeExternalService; +import edu.kit.datamanager.idoris.datatypes.dao.IAtomicDataTypeDao; +import edu.kit.datamanager.idoris.datatypes.dao.ITypeProfileDao; +import edu.kit.datamanager.idoris.datatypes.dto.DataTypeDto; +import edu.kit.datamanager.idoris.datatypes.mappers.AtomicDataTypeMapper; +import edu.kit.datamanager.idoris.datatypes.mappers.TypeProfileMapper; +import edu.kit.datamanager.idoris.operations.api.IOperationExternalService; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Service implementation for generic DataType operations. + * Handles both AtomicDataType and TypeProfile entities. + */ +@Service +@Slf4j +@Observed(contextualName = "dataTypeDtoService") +class DataTypeDtoService implements IDataTypeExternalService { + + private final IAtomicDataTypeDao atomicDataTypeDao; + private final ITypeProfileDao typeProfileDao; + private final AtomicDataTypeMapper atomicDataTypeMapper; + private final TypeProfileMapper typeProfileMapper; + private final IOperationExternalService operationService; + + public DataTypeDtoService(IAtomicDataTypeDao atomicDataTypeDao, + ITypeProfileDao typeProfileDao, + AtomicDataTypeMapper atomicDataTypeMapper, + TypeProfileMapper typeProfileMapper, + IOperationExternalService operationService) { + this.atomicDataTypeDao = atomicDataTypeDao; + this.typeProfileDao = typeProfileDao; + this.atomicDataTypeMapper = atomicDataTypeMapper; + this.typeProfileMapper = typeProfileMapper; + this.operationService = operationService; + } + + @Override + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "dataTypeDtoService.list", histogram = true) + @Counted(value = "dataTypeDtoService.list.count") + public List list() { + log.debug("Listing all DataTypes"); + + List allDataTypes = new ArrayList<>(); + + // Add all TypeProfiles + List typeProfiles = typeProfileDao.findAll(); + typeProfiles.forEach(tp -> allDataTypes.add(typeProfileMapper.toDto(tp))); + + // Add all AtomicDataTypes + List atomicDataTypes = atomicDataTypeDao.findAll(); + atomicDataTypes.forEach(adt -> allDataTypes.add(atomicDataTypeMapper.toDto(adt))); + + return allDataTypes; + } + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "dataTypeDtoService.delete", histogram = true) + @Counted(value = "dataTypeDtoService.delete.count") + public void delete(@SpanAttribute String id) { + log.debug("Deleting DataType with ID: {}", id); + + // Try to find and delete as TypeProfile first + Optional typeProfile = typeProfileDao.findById(id); + if (typeProfile.isPresent()) { + typeProfileDao.delete(typeProfile.get()); + return; + } + + // Try to find and delete as AtomicDataType + Optional atomicDataType = atomicDataTypeDao.findById(id); + if (atomicDataType.isPresent()) { + atomicDataTypeDao.delete(atomicDataType.get()); + return; + } + + throw new IllegalArgumentException("DataType not found: " + id); + } + + @Override + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "dataTypeDtoService.getInheritanceHierarchy", histogram = true) + @Counted(value = "dataTypeDtoService.getInheritanceHierarchy.count") + public Object getInheritanceHierarchy(@SpanAttribute String id) { + log.debug("Getting inheritance hierarchy for DataType: {}", id); + + // Check if it's a TypeProfile (which has inheritance) + Optional typeProfile = typeProfileDao.findById(id); + if (typeProfile.isPresent()) { + // Use the inheritance chain logic from TypeProfile DAO + Iterable inheritanceChain = typeProfileDao.findAllTypeProfilesInInheritanceChain(id); + + Map hierarchy = new HashMap<>(); + hierarchy.put("rootId", id); + hierarchy.put("rootType", "PROFILE"); + + List> parents = new ArrayList<>(); + for (TypeProfile parent : inheritanceChain) { + Map parentNode = new HashMap<>(); + parentNode.put("id", parent.getId()); + parentNode.put("name", parent.getName()); + parentNode.put("type", "PROFILE"); + parents.add(parentNode); + } + + hierarchy.put("inheritsFrom", parents); + return hierarchy; + } + + // Check if it's an AtomicDataType + Optional atomicDataType = atomicDataTypeDao.findById(id); + if (atomicDataType.isPresent()) { + Map hierarchy = new HashMap<>(); + hierarchy.put("rootId", id); + hierarchy.put("rootType", "ATOMIC"); + hierarchy.put("inheritsFrom", List.of()); // AtomicDataTypes don't have inheritance + return hierarchy; + } + + throw new IllegalArgumentException("DataType not found: " + id); + } + + @Override + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "dataTypeDtoService.getOperationsForDataType", histogram = true) + @Counted(value = "dataTypeDtoService.getOperationsForDataType.count") + public List getOperationsForDataType(@SpanAttribute String id) { + log.debug("Getting operations for DataType: {}", id); + + // Verify the DataType exists + if (get(id).isEmpty()) { + throw new IllegalArgumentException("DataType not found: " + id); + } + + // Use the Operations external logic (DTO-first) to get operations for this DataType + List operations = operationService.getOperationsForDataType(id); + return operations.stream() + .map(dto -> { + Map operationMap = new HashMap<>(); + operationMap.put("id", dto.getInternalId()); + operationMap.put("name", dto.getName()); + operationMap.put("description", dto.getDescription()); + operationMap.put("executableOnAttributeId", dto.getExecutableOnAttributeId()); + operationMap.put("returnAttributeIds", dto.getReturnAttributeIds()); + operationMap.put("environmentAttributeIds", dto.getEnvironmentAttributeIds()); + operationMap.put("executionStepIds", dto.getExecutionStepIds()); + return operationMap; + }) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "dataTypeDtoService.get", histogram = true) + @Counted(value = "dataTypeDtoService.get.count") + public Optional get(@SpanAttribute String id) { + log.debug("Getting DataType with ID: {}", id); + + // Try to find as TypeProfile first + Optional typeProfile = typeProfileDao.findById(id); + if (typeProfile.isPresent()) { + return Optional.of(typeProfileMapper.toDto(typeProfile.get())); + } + + // Try to find as AtomicDataType + Optional atomicDataType = atomicDataTypeDao.findById(id); + if (atomicDataType.isPresent()) { + return Optional.of(atomicDataTypeMapper.toDto(atomicDataType.get())); + } + + return Optional.empty(); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/TypeProfileDtoService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/TypeProfileDtoService.java new file mode 100644 index 0000000..759775e --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/TypeProfileDtoService.java @@ -0,0 +1,294 @@ +/* + * 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.idoris.datatypes.services; + +import edu.kit.datamanager.idoris.core.configuration.ApplicationProperties; +import edu.kit.datamanager.idoris.core.domain.TypeProfile; +import edu.kit.datamanager.idoris.core.events.EventPublisherService; +import edu.kit.datamanager.idoris.core.exceptions.ValidationException; +import edu.kit.datamanager.idoris.datatypes.api.ITypeProfileExternalService; +import edu.kit.datamanager.idoris.datatypes.dao.ITypeProfileDao; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileInheritance; +import edu.kit.datamanager.idoris.datatypes.mappers.TypeProfileMapper; +import edu.kit.datamanager.idoris.operations.api.IOperationExternalService; +import edu.kit.datamanager.idoris.rules.validation.ValidationPolicyValidator; +import edu.kit.datamanager.idoris.rules.validation.ValidationResult; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * DTO-first logic for TypeProfiles, implementing exported external and internal APIs. + * Service implementation is package-private to enforce module boundaries. + */ +@Service +@Slf4j +@Observed(contextualName = "typeProfileDtoService") +class TypeProfileDtoService implements ITypeProfileExternalService { + + private final ITypeProfileDao typeProfileDao; + private final EventPublisherService eventPublisher; + private final TypeProfileMapper mapper; + private final ApplicationProperties appProps; + private final IOperationExternalService operationService; + + public TypeProfileDtoService(ITypeProfileDao typeProfileDao, EventPublisherService eventPublisher, TypeProfileMapper mapper, ApplicationProperties appProps, IOperationExternalService operationService) { + this.typeProfileDao = typeProfileDao; + this.eventPublisher = eventPublisher; + this.mapper = mapper; + this.appProps = appProps; + this.operationService = operationService; + } + + // ===== External API ===== + + @Override + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileDtoService.create", histogram = true) + @Counted(value = "typeProfileDtoService.create.count") + public TypeProfileDto create(@SpanAttribute TypeProfileDto dto) { + log.debug("Creating TypeProfile DTO: {}", dto.getName()); + TypeProfile entity = mapper.toEntity(dto); + TypeProfile saved = typeProfileDao.save(entity); + // Handle relationships if provided + if (dto.getInheritsFromIds() != null && !dto.getInheritsFromIds().isEmpty()) { + typeProfileDao.addInheritsFrom(saved.getId(), dto.getInheritsFromIds()); + } + if (dto.getAttributeIds() != null && !dto.getAttributeIds().isEmpty()) { + typeProfileDao.addAttributes(saved.getId(), dto.getAttributeIds()); + } + // Reload to include relationships and run validation before event publishing + TypeProfile reloaded = typeProfileDao.findById(saved.getId()).orElse(saved); + validateOrThrow(reloaded); + // Publish module-scoped created event after successful validation + eventPublisher.publishEvent(new edu.kit.datamanager.idoris.datatypes.events.TypeProfileCreatedEvent(reloaded.getId(), mapper.toDto(reloaded))); + return mapper.toDto(reloaded); + } + + /** + * Validate the given TypeProfile based on application validation policy and throw if invalid. + * STRICT: errors or warnings cause failure. LAX: only errors cause failure. + */ + private void validateOrThrow(TypeProfile typeProfile) { + ValidationPolicyValidator validator = new ValidationPolicyValidator(); + ValidationResult result = typeProfile.execute(validator); + boolean strict = appProps.getValidationPolicy() == ApplicationProperties.ValidationPolicy.STRICT; + boolean hasErrors = result.getErrorCount() > 0; + boolean hasWarnings = result.getWarningCount() > 0; + if (hasErrors || (strict && hasWarnings)) { + String message = "TypeProfile validation failed: " + result; + throw new ValidationException(message, result); + } + } + + @Override + @Transactional + public TypeProfileDto update(String id, TypeProfileDto dto) { + TypeProfile existing = typeProfileDao.findById(id).orElseThrow(() -> new IllegalArgumentException("TypeProfile not found: " + id)); + Long previousVersion = existing.getVersion(); + mapper.applyPatch(dto, existing); + TypeProfile saved = typeProfileDao.save(existing); + // Reload and validate before publishing event + TypeProfile reloaded = typeProfileDao.findById(saved.getId()).orElse(saved); + validateOrThrow(reloaded); + // Publish module-scoped updated event + eventPublisher.publishEvent(new edu.kit.datamanager.idoris.datatypes.events.TypeProfileUpdatedEvent(reloaded.getId(), previousVersion, mapper.toDto(reloaded))); + return mapper.toDto(reloaded); + } + + @Override + @Transactional + public TypeProfileDto patch(String id, TypeProfileDto dto) { + TypeProfile existing = typeProfileDao.findById(id).orElseThrow(() -> new IllegalArgumentException("TypeProfile not found: " + id)); + Long previousVersion = existing.getVersion(); + mapper.applyPatch(dto, existing); + TypeProfile saved = typeProfileDao.save(existing); + // Reload and validate before publishing event + TypeProfile reloaded = typeProfileDao.findById(saved.getId()).orElse(saved); + validateOrThrow(reloaded); + // Publish module-scoped patched event + eventPublisher.publishEvent(new edu.kit.datamanager.idoris.datatypes.events.TypeProfilePatchedEvent(reloaded.getId(), previousVersion, mapper.toDto(reloaded))); + return mapper.toDto(reloaded); + } + + @Override + @Transactional + public void delete(String id) { + TypeProfile existing = typeProfileDao.findById(id).orElseThrow(() -> new IllegalArgumentException("TypeProfile not found: " + id)); + // Publish module-scoped deleted event before deletion + eventPublisher.publishEvent(new edu.kit.datamanager.idoris.datatypes.events.TypeProfileDeletedEvent(existing.getId(), mapper.toDto(existing))); + typeProfileDao.delete(existing); + } + + @Override + @Transactional(readOnly = true) + public Optional get(String id) { + return typeProfileDao.findById(id).map(mapper::toDto); + } + + @Override + @Transactional(readOnly = true) + public List list() { + return typeProfileDao.findAll().stream().map(mapper::toDto).toList(); + } + + @Override + @Transactional + public TypeProfileDto addInheritsFrom(String profileId, Set parentIds) { + if (parentIds == null || parentIds.isEmpty()) return get(profileId).orElseThrow(); + typeProfileDao.addInheritsFrom(profileId, parentIds); + return get(profileId).orElseThrow(); + } + + @Override + @Transactional + public TypeProfileDto removeInheritsFrom(String profileId, Set parentIds) { + if (parentIds == null || parentIds.isEmpty()) return get(profileId).orElseThrow(); + typeProfileDao.removeInheritsFrom(profileId, parentIds); + return get(profileId).orElseThrow(); + } + + @Override + @Transactional + public TypeProfileDto addAttributes(String profileId, Set attributeIds) { + if (attributeIds == null || attributeIds.isEmpty()) return get(profileId).orElseThrow(); + typeProfileDao.addAttributes(profileId, attributeIds); + return get(profileId).orElseThrow(); + } + + // ===== Internal API ===== + + @Override + @Transactional + public TypeProfileDto removeAttributes(String profileId, Set attributeIds) { + if (attributeIds == null || attributeIds.isEmpty()) return get(profileId).orElseThrow(); + typeProfileDao.removeAttributes(profileId, attributeIds); + return get(profileId).orElseThrow(); + } + + @Override + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileDtoService.getInheritedAttributes", histogram = true) + @Counted(value = "typeProfileDtoService.getInheritedAttributes.count") + public Set getInheritedAttributes(@SpanAttribute String id) { + log.debug("Getting inherited attributes for TypeProfile: {}", id); + // Find all TypeProfiles in the inheritance chain with their attributes + Iterable inheritanceChain = typeProfileDao.findAllTypeProfilesWithTheirAttributesInInheritanceChain(id); + + Set inheritedAttributeIds = new java.util.HashSet<>(); + for (TypeProfile profile : inheritanceChain) { + if (profile.getAttributes() != null) { + profile.getAttributes().forEach(attribute -> { + if (attribute.getId() != null) { + inheritedAttributeIds.add(attribute.getId()); + } + }); + } + } + + return inheritedAttributeIds; + } + + @Override + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileDtoService.getInheritanceTree", histogram = true) + @Counted(value = "typeProfileDtoService.getInheritanceTree.count") + public Object getInheritanceTree(@SpanAttribute String id) { + log.debug("Getting inheritance tree for TypeProfile: {}", id); + + // Get the root TypeProfile to get its name + TypeProfile rootProfile = typeProfileDao.findById(id).orElse(null); + if (rootProfile == null) { + throw new IllegalArgumentException("TypeProfile not found: " + id); + } + + // Find all TypeProfiles in the inheritance chain + Iterable inheritanceChain = typeProfileDao.findAllTypeProfilesInInheritanceChain(id); + + // Convert to TypeProfileInheritance structure + java.util.List parents = new java.util.ArrayList<>(); + int level = 0; + for (TypeProfile parent : inheritanceChain) { + java.util.List directAttributes = new java.util.ArrayList<>(); + if (parent.getAttributes() != null) { + parent.getAttributes().forEach(attr -> directAttributes.add(attr.getId())); + } + + TypeProfileInheritance.TypeProfileNode parentNode = + TypeProfileInheritance.TypeProfileNode.builder() + .id(parent.getId()) + .name(parent.getName().toString()) + .description(parent.getDescription().toString()) + .level(level++) + .directAttributes(directAttributes) + .build(); + parents.add(parentNode); + } + + return TypeProfileInheritance.builder() + .rootId(id) + .rootName(rootProfile.getName().toString()) + .inheritsFrom(parents) + .build(); + } + + @Override + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileDtoService.getOperationsForTypeProfile", histogram = true) + @Counted(value = "typeProfileDtoService.getOperationsForTypeProfile.count") + public List getOperationsForTypeProfile(@SpanAttribute String id) { + log.debug("Getting operations for TypeProfile: {}", id); + + // Verify the TypeProfile exists + if (typeProfileDao.findById(id).isEmpty()) { + throw new IllegalArgumentException("TypeProfile not found: " + id); + } + + // Get operations that can be executed on this TypeProfile via external logic (DTO-first) + java.util.List operations = operationService.getOperationsForDataType(id); + + // Convert OperationDto to response maps for the API response + java.util.List operationList = new java.util.ArrayList<>(); + for (edu.kit.datamanager.idoris.operations.dto.OperationResponseDto dto : operations) { + java.util.Map operationMap = new java.util.HashMap<>(); + operationMap.put("id", dto.getInternalId()); + operationMap.put("name", dto.getName()); + operationMap.put("description", dto.getDescription()); + operationMap.put("executableOnAttributeId", dto.getExecutableOnAttributeId()); + operationMap.put("returnAttributeIds", dto.getReturnAttributeIds()); + operationMap.put("environmentAttributeIds", dto.getEnvironmentAttributeIds()); + operationMap.put("executionStepIds", dto.getExecutionStepIds()); + operationList.add(operationMap); + } + + return operationList; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/TypeProfileService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/TypeProfileService.java new file mode 100644 index 0000000..35f8b74 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/services/TypeProfileService.java @@ -0,0 +1,240 @@ +/* + * 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.idoris.datatypes.services; + +import edu.kit.datamanager.idoris.core.domain.TypeProfile; +import edu.kit.datamanager.idoris.core.events.EventPublisherService; +import edu.kit.datamanager.idoris.datatypes.dao.ITypeProfileDao; +import edu.kit.datamanager.idoris.rules.validation.ValidationPolicyValidator; +import edu.kit.datamanager.idoris.rules.validation.ValidationResult; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Service for managing TypeProfile entities. + * This logic provides methods for creating, updating, and retrieving TypeProfile entities. + * It publishes domain events when entities are created, updated, or deleted. + */ +@Service +@Slf4j +@Observed(contextualName = "typeProfileService") +public class TypeProfileService { + private final ITypeProfileDao typeProfileDao; + private final EventPublisherService eventPublisher; + + /** + * Creates a new TypeProfileService with the given dependencies. + * + * @param typeProfileDao the TypeProfile repository + * @param eventPublisher the event publisher logic + */ + public TypeProfileService(ITypeProfileDao typeProfileDao, EventPublisherService eventPublisher) { + this.typeProfileDao = typeProfileDao; + this.eventPublisher = eventPublisher; + } + + /** + * Creates a new TypeProfile entity. + * + * @param typeProfile the TypeProfile entity to create + * @return the created TypeProfile entity + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileService.createTypeProfile", description = "Time taken to create a type profile", histogram = true) + @Counted(value = "typeProfileService.createTypeProfile.count", description = "Number of type profile creations") + public TypeProfile createTypeProfile(@SpanAttribute TypeProfile typeProfile) { + log.debug("Creating TypeProfile: {}", typeProfile); + TypeProfile saved = typeProfileDao.save(typeProfile); + eventPublisher.publishEntityCreated(saved); + log.info("Created TypeProfile with PID: {}", saved.getId()); + return saved; + } + + /** + * Updates an existing TypeProfile entity. + * + * @param typeProfile the TypeProfile entity to update + * @return the updated TypeProfile entity + * @throws IllegalArgumentException if the TypeProfile does not exist + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileService.updateTypeProfile", description = "Time taken to update a type profile", histogram = true) + @Counted(value = "typeProfileService.updateTypeProfile.count", description = "Number of type profile updates") + public TypeProfile updateTypeProfile(@SpanAttribute TypeProfile typeProfile) { + log.debug("Updating TypeProfile: {}", typeProfile); + if (typeProfile.getId() == null || typeProfile.getId().isEmpty()) { + throw new IllegalArgumentException("TypeProfile must have a PID to be updated"); + } + // Get the current version before updating + TypeProfile existing = typeProfileDao.findById(typeProfile.getId()) + .orElseThrow(() -> new IllegalArgumentException("TypeProfile not found with PID: " + typeProfile.getId())); + Long previousVersion = existing.getVersion(); + TypeProfile saved = typeProfileDao.save(typeProfile); + eventPublisher.publishEntityUpdated(saved, previousVersion); + log.info("Updated TypeProfile with PID: {}", saved.getId()); + return saved; + } + + /** + * Deletes a TypeProfile entity. + * + * @param id the PID or internal ID of the TypeProfile to delete + * @throws IllegalArgumentException if the TypeProfile does not exist + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileService.deleteTypeProfile", description = "Time taken to delete a type profile", histogram = true) + @Counted(value = "typeProfileService.deleteTypeProfile.count", description = "Number of type profile deletions") + public void deleteTypeProfile(@SpanAttribute String id) { + log.debug("Deleting TypeProfile with ID: {}", id); + + TypeProfile typeProfile = typeProfileDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("TypeProfile not found with ID: " + id)); + + typeProfileDao.delete(typeProfile); + eventPublisher.publishEntityDeleted(typeProfile); + log.info("Deleted TypeProfile with ID: {}", id); + } + + /** + * Retrieves a TypeProfile entity by its PID or internal ID. + * + * @param id the PID or internal ID of the TypeProfile to retrieve + * @return an Optional containing the TypeProfile, or empty if not found + */ + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileService.getTypeProfile", description = "Time taken to get a type profile", histogram = true) + @Counted(value = "typeProfileService.getTypeProfile.count", description = "Number of type profile retrievals") + public Optional getTypeProfile(@SpanAttribute String id) { + log.debug("Retrieving TypeProfile with ID: {}", id); + return typeProfileDao.findById(id); + } + + /** + * Retrieves all TypeProfile entities. + * + * @return a list of all TypeProfile entities + */ + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileService.getAllTypeProfiles", description = "Time taken to get all type profiles", histogram = true) + @Counted(value = "typeProfileService.getAllTypeProfiles.count", description = "Number of get all type profiles requests") + public List getAllTypeProfiles() { + log.debug("Retrieving all TypeProfiles"); + return typeProfileDao.findAll(); + } + + /** + * Validates a TypeProfile entity. + * + * @param id the PID or internal ID of the TypeProfile to validate + * @return the validation result + * @throws IllegalArgumentException if the TypeProfile does not exist + */ + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileService.validateTypeProfile", description = "Time taken to validate a type profile", histogram = true) + @Counted(value = "typeProfileService.validateTypeProfile.count", description = "Number of type profile validations") + public ValidationResult validateTypeProfile(@SpanAttribute String id) { + log.debug("Validating TypeProfile with ID: {}", id); + + TypeProfile typeProfile = typeProfileDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("TypeProfile not found with ID: " + id)); + + ValidationPolicyValidator validator = new ValidationPolicyValidator(); + return typeProfile.execute(validator); + } + + /** + * Retrieves all TypeProfiles in the inheritance chain of a TypeProfile. + * + * @param id the PID or internal ID of the TypeProfile + * @return an Iterable of TypeProfiles in the inheritance chain + * @throws IllegalArgumentException if the TypeProfile does not exist + */ + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileService.getInheritanceChain", description = "Time taken to get inheritance chain", histogram = true) + @Counted(value = "typeProfileService.getInheritanceChain.count", description = "Number of inheritance chain retrievals") + public Iterable getInheritanceChain(@SpanAttribute String id) { + log.debug("Retrieving inheritance chain for TypeProfile with ID: {}", id); + + TypeProfile typeProfile = typeProfileDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("TypeProfile not found with ID: " + id)); + + return typeProfileDao.findAllTypeProfilesInInheritanceChain(typeProfile.getId()); + } + + /** + * Partially updates an existing TypeProfile entity. + * + * @param id the PID or internal ID of the TypeProfile to patch + * @param typeProfilePatch the partial TypeProfile entity with fields to update + * @return the patched TypeProfile entity + * @throws IllegalArgumentException if the TypeProfile does not exist + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "typeProfileService.patchTypeProfile", description = "Time taken to patch a type profile", histogram = true) + @Counted(value = "typeProfileService.patchTypeProfile.count", description = "Number of type profile patches") + public TypeProfile patchTypeProfile(@SpanAttribute String id, @SpanAttribute TypeProfile typeProfilePatch) { + log.debug("Patching TypeProfile with ID: {}, patch: {}", id, typeProfilePatch); + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("TypeProfile ID cannot be null or empty"); + } + + // Get the current entity + TypeProfile existing = typeProfileDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("TypeProfile not found with ID: " + id)); + Long previousVersion = existing.getVersion(); + + // Apply non-null fields from the patch to the existing entity + if (typeProfilePatch.getName() != null) { + existing.setName(typeProfilePatch.getName()); + } + if (typeProfilePatch.getDescription() != null) { + existing.setDescription(typeProfilePatch.getDescription()); + } + if (typeProfilePatch.getAttributes() != null && !typeProfilePatch.getAttributes().isEmpty()) { + existing.setAttributes(typeProfilePatch.getAttributes()); + } + if (typeProfilePatch.getInheritsFrom() != null && !typeProfilePatch.getInheritsFrom().isEmpty()) { + existing.setInheritsFrom(typeProfilePatch.getInheritsFrom()); + } + + // Save the updated entity + TypeProfile saved = typeProfileDao.save(existing); + + // Publish the patched event + eventPublisher.publishEntityPatched(saved, previousVersion); + + log.info("Patched TypeProfile with PID: {}", saved.getId()); + return saved; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/api/IAtomicDataTypeApi.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/api/IAtomicDataTypeApi.java new file mode 100644 index 0000000..6483320 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/api/IAtomicDataTypeApi.java @@ -0,0 +1,211 @@ +/* + * 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.idoris.datatypes.web.api; + +import edu.kit.datamanager.idoris.core.domain.AtomicDataType; +import edu.kit.datamanager.idoris.datatypes.dto.AtomicDataTypeDto; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * API interface for AtomicDataType endpoints. + * This interface defines the REST API for managing AtomicDataType entities. + */ +@RestController +@RequestMapping(value = "/api/v1/atomicDataTypes", version = "1") +@Tag(name = "AtomicDataType", description = "API for managing AtomicDataTypes") +@Observed +public interface IAtomicDataTypeApi { + + /** + * Gets all AtomicDataType entities. + * + * @return a collection of all AtomicDataType entities + */ + @GetMapping + @Operation( + summary = "Get all AtomicDataTypes", + description = "Returns a collection of all AtomicDataType entities", + responses = { + @ApiResponse(responseCode = "200", description = "AtomicDataTypes found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = AtomicDataTypeDto.class))) + } + ) + @WithSpan + ResponseEntity>> getAllAtomicDataTypes(); + + /** + * Gets an AtomicDataType entity by its PID or internal ID. + * + * @param id the PID or internal ID of the AtomicDataType to retrieve + * @return the AtomicDataType entity + */ + @GetMapping("/{id}") + @Operation( + summary = "Get an AtomicDataType by PID or internal ID", + description = "Returns an AtomicDataType entity by its PID or internal ID", + responses = { + @ApiResponse(responseCode = "200", description = "AtomicDataType found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = AtomicDataType.class))), + @ApiResponse(responseCode = "404", description = "AtomicDataType not found") + } + ) + @WithSpan + ResponseEntity> getAtomicDataType( + @SpanAttribute("atomicDataType.id") + @Parameter(description = "PID or internal ID of the AtomicDataType", required = true) + @PathVariable("id") String id); + + /** + * Creates a new AtomicDataType entity. + * The entity is validated before saving. + * + * @param atomicDataType the AtomicDataType entity to create + * @return the created AtomicDataType entity + */ + @PostMapping + @Operation( + summary = "Create a new AtomicDataType", + description = "Creates a new AtomicDataType entity after validating it", + responses = { + @ApiResponse(responseCode = "201", description = "AtomicDataType created", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = AtomicDataTypeDto.class))), + @ApiResponse(responseCode = "400", description = "Invalid input or validation failed") + } + ) + @WithSpan + ResponseEntity> createAtomicDataType( + + @Parameter(description = "AtomicDataType to create", required = true) + @Valid @RequestBody AtomicDataTypeDto atomicDataType); + + /** + * Updates an existing AtomicDataType entity. + * The entity is validated before saving. + * + * @param id the PID or internal ID of the AtomicDataType to update + * @param atomicDataType the updated AtomicDataType entity + * @return the updated AtomicDataType entity + */ + @PutMapping("/{id}") + @Operation( + summary = "Update an AtomicDataType", + description = "Updates an existing AtomicDataType entity after validating it", + responses = { + @ApiResponse(responseCode = "200", description = "AtomicDataType updated", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = AtomicDataTypeDto.class))), + @ApiResponse(responseCode = "400", description = "Invalid input or validation failed"), + @ApiResponse(responseCode = "404", description = "AtomicDataType not found") + } + ) + @WithSpan + ResponseEntity> updateAtomicDataType( + @SpanAttribute("atomicDataType.id") + @Parameter(description = "PID or internal ID of the AtomicDataType", required = true) + @PathVariable String id, + + @Parameter(description = "Updated AtomicDataType", required = true) + @Valid @RequestBody AtomicDataTypeDto atomicDataType); + + /** + * Deletes an AtomicDataType entity. + * + * @param id the PID or internal ID of the AtomicDataType to delete + * @return no content + */ + @DeleteMapping("/{id}") + @Operation( + summary = "Delete an AtomicDataType", + description = "Deletes an AtomicDataType entity", + responses = { + @ApiResponse(responseCode = "204", description = "AtomicDataType deleted"), + @ApiResponse(responseCode = "404", description = "AtomicDataType not found") + } + ) + @WithSpan + ResponseEntity deleteAtomicDataType( + @SpanAttribute("atomicDataType.id") + @Parameter(description = "PID or internal ID of the AtomicDataType", required = true) + @PathVariable String id); + + /** + * Gets operations for an AtomicDataType. + * + * @param id the PID or internal ID of the AtomicDataType + * @return a collection of operations for the AtomicDataType + */ + @GetMapping("/{id}/operations") + @Operation( + summary = "Get operations for an AtomicDataType", + description = "Returns a collection of operations that can be executed on an AtomicDataType", + responses = { + @ApiResponse(responseCode = "200", description = "Operations found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = OperationResponseDto.class))), + @ApiResponse(responseCode = "404", description = "AtomicDataType not found") + } + ) + @WithSpan + ResponseEntity>> getOperationsForAtomicDataType( + @SpanAttribute("atomicDataType.id") + @Parameter(description = "PID or internal ID of the AtomicDataType", required = true) + @PathVariable String id); + + /** + * Partially updates an AtomicDataType entity. + * + * @param id the PID or internal ID of the AtomicDataType to patch + * @param atomicDataTypePatch the partial AtomicDataType entity with fields to update + * @return the patched AtomicDataType entity + */ + @PatchMapping("/{id}") + @Operation( + summary = "Partially update an AtomicDataType", + description = "Updates specific fields of an existing AtomicDataType entity", + responses = { + @ApiResponse(responseCode = "200", description = "AtomicDataType patched", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = AtomicDataType.class))), + @ApiResponse(responseCode = "400", description = "Invalid input"), + @ApiResponse(responseCode = "404", description = "AtomicDataType not found") + } + ) + @WithSpan + ResponseEntity> patchAtomicDataType( + @SpanAttribute + @Parameter(description = "PID or internal ID of the AtomicDataType", required = true) + @PathVariable String id, + @Parameter(description = "Partial AtomicDataType with fields to update", required = true) + @RequestBody AtomicDataTypeDto atomicDataTypePatch); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/api/ITypeProfileApi.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/api/ITypeProfileApi.java new file mode 100644 index 0000000..d9e2dad --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/api/ITypeProfileApi.java @@ -0,0 +1,234 @@ +/* + * 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.idoris.datatypes.web.api; + +import edu.kit.datamanager.idoris.core.domain.Operation; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import io.micrometer.observation.annotation.Observed; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * API interface for TypeProfile endpoints. + * This interface defines the REST API for managing TypeProfile entities using DTOs. + */ +@RestController +@RequestMapping(value = "/api/v1/typeProfiles") +@Tag(name = "TypeProfile", description = "API for managing TypeProfiles") +@Observed +public interface ITypeProfileApi { + + /** + * Gets all TypeProfile entities. + * + * @return a collection of all TypeProfile entities + */ + @GetMapping + @io.swagger.v3.oas.annotations.Operation( + summary = "Get all TypeProfiles", + description = "Returns a collection of all TypeProfile entities", + responses = { + @ApiResponse(responseCode = "200", description = "TypeProfiles found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = TypeProfileDto.class))) + } + ) + ResponseEntity>> getAllTypeProfiles(); + + /** + * Gets a TypeProfile entity by its PID or internal ID. + * + * @param id the PID or internal ID of the TypeProfile to retrieve + * @return the TypeProfile entity + */ + @GetMapping("/{id}") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get a TypeProfile by PID or internal ID", + description = "Returns a TypeProfile entity by its PID or internal ID", + responses = { + @ApiResponse(responseCode = "200", description = "TypeProfile found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = TypeProfileDto.class))), + @ApiResponse(responseCode = "404", description = "TypeProfile not found") + } + ) + ResponseEntity> getTypeProfile( + @Parameter(description = "PID or internal ID of the TypeProfile", required = true) + @PathVariable String id); + + /** + * Gets operations for a TypeProfile. + * + * @param id the PID or internal ID of the TypeProfile + * @return a collection of operations for the TypeProfile + */ + @GetMapping("/{id}/operations") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get operations for a TypeProfile", + description = "Returns a collection of operations that can be executed on a TypeProfile", + responses = { + @ApiResponse(responseCode = "200", description = "Operations found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = Operation.class))), + @ApiResponse(responseCode = "404", description = "TypeProfile not found") + } + ) + ResponseEntity>> getOperationsForTypeProfile( + @Parameter(description = "PID or internal ID of the TypeProfile", required = true) + @PathVariable String id); + + + /** + * Gets inherited attributes for a TypeProfile. + * + * @param id the PID or internal ID of the TypeProfile + * @return a collection of inherited attribute IDs + */ + @GetMapping("/{id}/inheritedAttributes") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get inherited attributes of a TypeProfile", + description = "Returns a collection of attribute IDs inherited by a TypeProfile", + responses = { + @ApiResponse(responseCode = "200", description = "Inherited attributes found"), + @ApiResponse(responseCode = "404", description = "TypeProfile not found") + } + ) + ResponseEntity> getInheritedAttributes( + @Parameter(description = "PID or internal ID of the TypeProfile", required = true) + @PathVariable String id); + + /** + * Creates a new TypeProfile entity. + * The entity is validated before saving. + * + * @param typeProfile the TypeProfile entity to create + * @return the created TypeProfile entity + */ + @PostMapping + @io.swagger.v3.oas.annotations.Operation( + summary = "Create a new TypeProfile", + description = "Creates a new TypeProfile entity after validating it", + responses = { + @ApiResponse(responseCode = "201", description = "TypeProfile created", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = TypeProfileDto.class))), + @ApiResponse(responseCode = "400", description = "Invalid input or validation failed") + } + ) + ResponseEntity> createTypeProfile( + @Parameter(description = "TypeProfile to create", required = true) + @Valid @RequestBody TypeProfileDto typeProfile); + + /** + * Updates an existing TypeProfile entity. + * The entity is validated before saving. + * + * @param id the PID or internal ID of the TypeProfile to update + * @param typeProfile the updated TypeProfile entity + * @return the updated TypeProfile entity + */ + @PutMapping("/{id}") + @io.swagger.v3.oas.annotations.Operation( + summary = "Update a TypeProfile", + description = "Updates an existing TypeProfile entity after validating it", + responses = { + @ApiResponse(responseCode = "200", description = "TypeProfile updated", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = TypeProfileDto.class))), + @ApiResponse(responseCode = "400", description = "Invalid input or validation failed"), + @ApiResponse(responseCode = "404", description = "TypeProfile not found") + } + ) + ResponseEntity> updateTypeProfile( + @Parameter(description = "PID or internal ID of the TypeProfile", required = true) + @PathVariable String id, + @Parameter(description = "Updated TypeProfile", required = true) + @Valid @RequestBody TypeProfileDto typeProfile); + + /** + * Deletes a TypeProfile entity. + * + * @param id the PID or internal ID of the TypeProfile to delete + * @return no content + */ + @DeleteMapping("/{id}") + @io.swagger.v3.oas.annotations.Operation( + summary = "Delete a TypeProfile", + description = "Deletes a TypeProfile entity", + responses = { + @ApiResponse(responseCode = "204", description = "TypeProfile deleted"), + @ApiResponse(responseCode = "404", description = "TypeProfile not found") + } + ) + ResponseEntity deleteTypeProfile( + @Parameter(description = "PID or internal ID of the TypeProfile", required = true) + @PathVariable String id); + + /** + * Gets the inheritance tree of a TypeProfile. + * + * @param id the PID or internal ID of the TypeProfile + * @return the inheritance tree + */ + @GetMapping("/{id}/inheritanceTree") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get inheritance tree of a TypeProfile", + description = "Returns the inheritance tree of a TypeProfile", + responses = { + @ApiResponse(responseCode = "200", description = "Inheritance tree found", + content = @Content(mediaType = "application/hal+json")), + @ApiResponse(responseCode = "404", description = "TypeProfile not found") + } + ) + ResponseEntity> getInheritanceTree( + @Parameter(description = "PID or internal ID of the TypeProfile", required = true) + @NotNull @PathVariable String id); + + /** + * Partially updates a TypeProfile entity. + * + * @param id the PID or internal ID of the TypeProfile to patch + * @param typeProfilePatch the partial TypeProfile entity with fields to update + * @return the patched TypeProfile entity + */ + @PatchMapping("/{id}") + @io.swagger.v3.oas.annotations.Operation( + summary = "Partially update a TypeProfile", + description = "Updates specific fields of an existing TypeProfile entity", + responses = { + @ApiResponse(responseCode = "200", description = "TypeProfile patched", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = TypeProfileDto.class))), + @ApiResponse(responseCode = "400", description = "Invalid input"), + @ApiResponse(responseCode = "404", description = "TypeProfile not found") + } + ) + ResponseEntity> patchTypeProfile( + @Parameter(description = "PID or internal ID of the TypeProfile", required = true) + @PathVariable String id, + @Parameter(description = "Partial TypeProfile with fields to update", required = true) + @RequestBody TypeProfileDto typeProfilePatch); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/api/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/api/package-info.java new file mode 100644 index 0000000..6419ec6 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/api/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("datatypes.web.api") +package edu.kit.datamanager.idoris.datatypes.web.api; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/hateoas/AtomicDataTypeModelAssembler.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/hateoas/AtomicDataTypeModelAssembler.java new file mode 100644 index 0000000..10ad477 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/hateoas/AtomicDataTypeModelAssembler.java @@ -0,0 +1,68 @@ +/* + * 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.idoris.datatypes.web.hateoas; + +import edu.kit.datamanager.idoris.core.web.hateoas.EntityModelAssembler; +import edu.kit.datamanager.idoris.datatypes.dto.AtomicDataTypeDto; +import edu.kit.datamanager.idoris.datatypes.web.v1.AtomicDataTypeController; +import edu.kit.datamanager.idoris.pids.api.IInternalPIDService; +import org.springframework.hateoas.EntityModel; +import org.springframework.stereotype.Component; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +/** + * Assembler for converting AtomicDataTypeDto objects to EntityModel objects with HATEOAS links. + */ +@Component +public class AtomicDataTypeModelAssembler implements EntityModelAssembler { + + private final IInternalPIDService internalPIDService; + + AtomicDataTypeModelAssembler(IInternalPIDService internalPIDService) { + this.internalPIDService = internalPIDService; + } + + /** + * Converts an AtomicDataTypeDto to an EntityModel with HATEOAS links. + * + * @param atomicDataTypeDto the AtomicDataTypeDto to convert + * @return an EntityModel containing the AtomicDataTypeDto and links + */ + @Override + public EntityModel toModel(AtomicDataTypeDto atomicDataTypeDto) { + EntityModel entityModel = EntityModel.of(atomicDataTypeDto); + + // Add self link + if (atomicDataTypeDto.getInternalId() != null) { + entityModel.add(linkTo(methodOn(AtomicDataTypeController.class).getAtomicDataType(atomicDataTypeDto.getInternalId())).withSelfRel()); + entityModel.add(internalPIDService.getPIDLinkForInternalID(atomicDataTypeDto.getInternalId())); + entityModel.add(linkTo(methodOn(AtomicDataTypeController.class).getOperationsForAtomicDataType(atomicDataTypeDto.getInternalId())).withRel("operations")); + } + + // Add link to all atomic data types + entityModel.add(linkTo(methodOn(AtomicDataTypeController.class).getAllAtomicDataTypes()).withRel("atomicDataTypes")); + + // Add link to inherits from if present + if (atomicDataTypeDto.getInheritsFromId() != null) { + entityModel.add(linkTo(methodOn(AtomicDataTypeController.class).getAtomicDataType(atomicDataTypeDto.getInheritsFromId())).withRel("inheritsFrom")); + } + + return entityModel; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/hateoas/DataTypeModelAssembler.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/hateoas/DataTypeModelAssembler.java new file mode 100644 index 0000000..74af313 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/hateoas/DataTypeModelAssembler.java @@ -0,0 +1,94 @@ +/* + * 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.idoris.datatypes.web.hateoas; + +import edu.kit.datamanager.idoris.datatypes.dto.AtomicDataTypeDto; +import edu.kit.datamanager.idoris.datatypes.dto.DataTypeDto; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import edu.kit.datamanager.idoris.datatypes.web.v1.DataTypeController; +import edu.kit.datamanager.idoris.datatypes.web.v1.TypeProfileController; +import edu.kit.datamanager.idoris.pids.api.IInternalPIDService; +import io.micrometer.observation.annotation.Observed; +import org.jspecify.annotations.NonNull; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +/** + * Assembler that adds HATEOAS links to DataType DTO responses. + * Handles both AtomicDataType and TypeProfile DTOs. + */ +@Component +@Observed(contextualName = "dataTypeDtoModelAssembler") +public class DataTypeModelAssembler implements RepresentationModelAssembler> { + + private final IInternalPIDService internalPIDService; + + public DataTypeModelAssembler(IInternalPIDService internalPIDService) { + this.internalPIDService = internalPIDService; + } + + @Override + public EntityModel toModel(@NonNull DataTypeDto dto) { + EntityModel model = EntityModel.of(dto); + + // Add self link to the unified DataType controller + if (dto.getInternalId() != null) { + model.add(linkTo(methodOn(DataTypeController.class).get(dto.getInternalId())).withSelfRel()); + } + + // Add collection link + model.add(linkTo(methodOn(DataTypeController.class).list()).withRel("collection")); + + // Add type-specific links + if (dto instanceof AtomicDataTypeDto && dto.getInternalId() != null) { + // For now, just add a templated link since AtomicDataTypeController may not exist yet + model.add(Link.of("/v1/atomicDataTypes/{id}").withRel("atomicDataType")); + } else if (dto instanceof TypeProfileDto && dto.getInternalId() != null) { + // Link to specific TypeProfile endpoint + model.add(linkTo(methodOn(TypeProfileController.class).getTypeProfile(dto.getInternalId())).withRel("typeProfile")); + + // TypeProfile-specific RESTful relationship links + model.add(linkTo(methodOn(TypeProfileController.class).getTypeProfile(dto.getInternalId())).withSelfRel()); + model.add(linkTo(methodOn(TypeProfileController.class).getInheritedAttributes(dto.getInternalId())).withRel("inheritedAttributes")); + model.add(linkTo(methodOn(TypeProfileController.class).getInheritanceTree(dto.getInternalId())).withRel("inheritanceTree")); + // Relationship collections + model.add(linkTo(methodOn(TypeProfileController.class).listInheritsFrom(dto.getInternalId())).withRel("inheritsFrom")); + model.add(linkTo(methodOn(TypeProfileController.class).listAttributes(dto.getInternalId())).withRel("attributes")); + } + + // Add common links + if (dto.getInternalId() != null) { + model.add(internalPIDService.getPIDLinkForInternalID(dto.getInternalId())); + model.add(linkTo(methodOn(DataTypeController.class).getInheritanceHierarchy(dto.getInternalId())).withRel("inheritanceHierarchy")); + model.add(linkTo(methodOn(DataTypeController.class).getOperations(dto.getInternalId())).withRel("operations")); + } + + return model; + } + + @Override + public CollectionModel> toCollectionModel(Iterable entities) { + CollectionModel> collection = RepresentationModelAssembler.super.toCollectionModel(entities); + collection.add(linkTo(methodOn(DataTypeController.class).list()).withSelfRel()); + return collection; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/hateoas/TypeProfileModelAssembler.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/hateoas/TypeProfileModelAssembler.java new file mode 100644 index 0000000..7dd9cd5 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/hateoas/TypeProfileModelAssembler.java @@ -0,0 +1,86 @@ +/* + * 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.idoris.datatypes.web.hateoas; + +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import edu.kit.datamanager.idoris.datatypes.web.v1.TypeProfileController; +import edu.kit.datamanager.idoris.pids.api.IInternalPIDService; +import io.micrometer.observation.annotation.Observed; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +/** + * Assembler that adds HATEOAS links to TypeProfileDto responses. + */ +@Component +@Observed(contextualName = "typeProfileDtoModelAssembler") +public class TypeProfileModelAssembler implements RepresentationModelAssembler> { + + @Autowired + private IInternalPIDService pidService; + + @Override + public EntityModel toModel(TypeProfileDto dto) { + EntityModel model = EntityModel.of(dto); + + // Collection link + model.add(linkTo(methodOn(TypeProfileController.class).getAllTypeProfiles()).withRel("collection")); + + // Add PID link if resolvable + if (dto.getInternalId() != null && pidService != null) { + pidService.getPIDLinkForInternalID(dto.getInternalId()).forEach(model::add); + } + + // Self and relation links + String base = "/v1/typeProfiles/{id}"; + if (dto.getInternalId() != null) { + model.add(linkTo(methodOn(TypeProfileController.class).getTypeProfile(dto.getInternalId())).withSelfRel()); + } else { + model.add(Link.of(base).withSelfRel().withTitle("self (templated)")); + } + model.add(Link.of(base + "/inheritsFrom:link").withRel("inheritsFrom:link")); + model.add(Link.of(base + "/inheritsFrom:unlink").withRel("inheritsFrom:unlink")); + model.add(Link.of(base + "/attributes:link").withRel("attributes:link")); + model.add(Link.of(base + "/attributes:unlink").withRel("attributes:unlink")); + + // Add link to inherited attributes using the DTO's ID + if (dto.getInternalId() != null) { + model.add(linkTo(methodOn(TypeProfileController.class).getInheritedAttributes(dto.getInternalId())).withRel("inheritedAttributes")); + + // Add link to inheritance tree + model.add(linkTo(methodOn(TypeProfileController.class).getInheritanceTree(dto.getInternalId())).withRel("inheritanceTree")); + + // Add link to operations + model.add(linkTo(methodOn(TypeProfileController.class).getOperationsForTypeProfile(dto.getInternalId())).withRel("operations")); + } + + return model; + } + + @Override + public CollectionModel> toCollectionModel(Iterable entities) { + CollectionModel> collection = RepresentationModelAssembler.super.toCollectionModel(entities); + collection.add(linkTo(methodOn(TypeProfileController.class).getAllTypeProfiles()).withSelfRel()); + return collection; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/v1/AtomicDataTypeController.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/v1/AtomicDataTypeController.java new file mode 100644 index 0000000..e8a5947 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/v1/AtomicDataTypeController.java @@ -0,0 +1,199 @@ +/* + * 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.idoris.datatypes.web.v1; + +import edu.kit.datamanager.idoris.core.configuration.PIISpanAttribute; +import edu.kit.datamanager.idoris.datatypes.api.IAtomicDataTypeExternalService; +import edu.kit.datamanager.idoris.datatypes.dto.AtomicDataTypeDto; +import edu.kit.datamanager.idoris.datatypes.web.api.IAtomicDataTypeApi; +import edu.kit.datamanager.idoris.datatypes.web.hateoas.AtomicDataTypeModelAssembler; +import edu.kit.datamanager.idoris.operations.api.IOperationExternalService; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +/** + * REST controller for AtomicDataType entities. + * This controller provides endpoints for managing AtomicDataType entities using DTOs exclusively. + */ +@RestController +@Tag(name = "AtomicDataType", description = "API for managing AtomicDataTypes") +@Slf4j +@Observed(contextualName = "atomicDataTypeController") +public class AtomicDataTypeController implements IAtomicDataTypeApi { + + private final IAtomicDataTypeExternalService atomicDataTypeService; + private final IOperationExternalService operationService; + private final AtomicDataTypeModelAssembler atomicDataTypeModelAssembler; + + public AtomicDataTypeController(IAtomicDataTypeExternalService atomicDataTypeService, IOperationExternalService operationService, AtomicDataTypeModelAssembler atomicDataTypeModelAssembler) { + this.atomicDataTypeService = atomicDataTypeService; + this.operationService = operationService; + this.atomicDataTypeModelAssembler = atomicDataTypeModelAssembler; + } + + /** + * {@inheritDoc} + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "atomicDataTypeController.getAllAtomicDataTypes", description = "Time taken to get all atomic data types", histogram = true) + @Counted(value = "atomicDataTypeController.getAllAtomicDataTypes.count", description = "Number of get all atomic data types requests") + public ResponseEntity>> getAllAtomicDataTypes() { + List dtos = atomicDataTypeService.list(); + List> entityModels = dtos.stream() + .map(atomicDataTypeModelAssembler::toModel) + .collect(Collectors.toList()); + + CollectionModel> collectionModel = CollectionModel.of( + entityModels, + linkTo(methodOn(AtomicDataTypeController.class).getAllAtomicDataTypes()).withSelfRel() + ); + + return ResponseEntity.ok(collectionModel); + } + + /** + * {@inheritDoc} + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "atomicDataTypeController.getAtomicDataType", description = "Time taken to get an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeController.getAtomicDataType.count", description = "Number of get atomic data type requests") + public ResponseEntity> getAtomicDataType(@PIISpanAttribute String id) { + return atomicDataTypeService.get(id) + .map(atomicDataTypeModelAssembler::toModel) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + /** + * {@inheritDoc} + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "atomicDataTypeController.createAtomicDataType", description = "Time taken to create an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeController.createAtomicDataType.count", description = "Number of create atomic data type requests") + public ResponseEntity> createAtomicDataType( + @SpanAttribute AtomicDataTypeDto atomicDataType) { + + AtomicDataTypeDto saved = atomicDataTypeService.create(atomicDataType); + return ResponseEntity.status(HttpStatus.CREATED).body(atomicDataTypeModelAssembler.toModel(saved)); + } + + /** + * {@inheritDoc} + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "atomicDataTypeController.updateAtomicDataType", description = "Time taken to update an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeController.updateAtomicDataType.count", description = "Number of update atomic data type requests") + public ResponseEntity> updateAtomicDataType( + @SpanAttribute String id, + @SpanAttribute AtomicDataTypeDto atomicDataType) { + + if (atomicDataTypeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + AtomicDataTypeDto updated = atomicDataTypeService.update(id, atomicDataType); + EntityModel entityModel = atomicDataTypeModelAssembler.toModel(updated); + return ResponseEntity.ok(entityModel); + } + + /** + * {@inheritDoc} + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "atomicDataTypeController.deleteAtomicDataType", description = "Time taken to delete an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeController.deleteAtomicDataType.count", description = "Number of delete atomic data type requests") + public ResponseEntity deleteAtomicDataType( + @SpanAttribute String id) { + if (atomicDataTypeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + atomicDataTypeService.delete(id); + return ResponseEntity.noContent().build(); + } + + /** + * {@inheritDoc} + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "atomicDataTypeController.getOperationsForAtomicDataType", description = "Time taken to get operations for an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeController.getOperationsForAtomicDataType.count", description = "Number of get operations for atomic data type requests") + public ResponseEntity>> getOperationsForAtomicDataType( + @SpanAttribute String id) { + if (atomicDataTypeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + List> operations = operationService.getOperationsForDataType(id).stream() + .map(dto -> EntityModel.of(dto, + linkTo(methodOn(AtomicDataTypeController.class).getOperationsForAtomicDataType(id)).withSelfRel(), + linkTo(methodOn(AtomicDataTypeController.class).getAtomicDataType(id)).withRel("atomicDataType"))) + .collect(Collectors.toList()); + + CollectionModel> collectionModel = CollectionModel.of( + operations, + linkTo(methodOn(AtomicDataTypeController.class).getOperationsForAtomicDataType(id)).withSelfRel(), + linkTo(methodOn(AtomicDataTypeController.class).getAtomicDataType(id)).withRel("atomicDataType") + ); + + return ResponseEntity.ok(collectionModel); + } + + /** + * {@inheritDoc} + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "atomicDataTypeController.patchAtomicDataType", description = "Time taken to patch an atomic data type", histogram = true) + @Counted(value = "atomicDataTypeController.patchAtomicDataType.count", description = "Number of patch atomic data type requests") + public ResponseEntity> patchAtomicDataType( + @SpanAttribute String id, + @SpanAttribute AtomicDataTypeDto atomicDataTypePatch) { + + if (atomicDataTypeService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + AtomicDataTypeDto patched = atomicDataTypeService.patch(id, atomicDataTypePatch); + EntityModel entityModel = atomicDataTypeModelAssembler.toModel(patched); + return ResponseEntity.ok(entityModel); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/v1/DataTypeController.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/v1/DataTypeController.java new file mode 100644 index 0000000..e2ed581 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/v1/DataTypeController.java @@ -0,0 +1,142 @@ +/* + * 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.idoris.datatypes.web.v1; + +import edu.kit.datamanager.idoris.datatypes.api.IDataTypeExternalService; +import edu.kit.datamanager.idoris.datatypes.dto.DataTypeDto; +import edu.kit.datamanager.idoris.datatypes.web.hateoas.DataTypeModelAssembler; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; + +/** + * DTO-first REST controller for generic DataType operations. + * Handles both AtomicDataType and TypeProfile entities through a unified interface. + */ +@RestController +@RequestMapping("/v1/dataTypes") +@io.swagger.v3.oas.annotations.tags.Tag(name = "DataType", description = "Unified API for managing DataTypes (AtomicDataType and TypeProfile)") +@Slf4j +@Observed(contextualName = "dataTypeController") +public class DataTypeController { + + @Autowired + private IDataTypeExternalService dataTypeService; + + @Autowired + private DataTypeModelAssembler assembler; + + @GetMapping + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "dataTypeController.list", description = "Time taken to list all DataTypes", histogram = true) + @Counted(value = "dataTypeController.list.count", description = "Number of DataType list requests") + @io.swagger.v3.oas.annotations.Operation( + summary = "List all DataTypes", + description = "Returns a collection of all DataType entities (both AtomicDataType and TypeProfile)" + ) + public ResponseEntity>> list() { + log.debug("Getting all DataTypes"); + List dataTypes = dataTypeService.list(); + return ResponseEntity.ok(assembler.toCollectionModel(dataTypes)); + } + + @GetMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "dataTypeController.get", description = "Time taken to get a DataType", histogram = true) + @Counted(value = "dataTypeController.get.count", description = "Number of DataType get requests") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get a DataType by ID", + description = "Returns a DataType entity by its ID (works for both AtomicDataType and TypeProfile)" + ) + public ResponseEntity> get(@SpanAttribute("dataType.id") @PathVariable String id) { + log.debug("Getting DataType with ID: {}", id); + Optional dto = dataTypeService.get(id); + return dto.map(d -> ResponseEntity.ok(assembler.toModel(d))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "dataTypeController.delete", description = "Time taken to delete a DataType", histogram = true) + @Counted(value = "dataTypeController.delete.count", description = "Number of DataType delete requests") + @io.swagger.v3.oas.annotations.Operation( + summary = "Delete a DataType", + description = "Deletes a DataType entity by its ID (works for both AtomicDataType and TypeProfile)" + ) + public ResponseEntity delete(@SpanAttribute("dataType.id") @PathVariable String id) { + log.debug("Deleting DataType with ID: {}", id); + try { + dataTypeService.delete(id); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/{id}/inheritanceHierarchy") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "dataTypeController.getInheritanceHierarchy", description = "Time taken to get DataType inheritance hierarchy", histogram = true) + @Counted(value = "dataTypeController.getInheritanceHierarchy.count", description = "Number of inheritance hierarchy requests") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get inheritance hierarchy for a DataType", + description = "Returns the inheritance hierarchy for a DataType (meaningful for TypeProfile, empty for AtomicDataType)" + ) + public ResponseEntity> getInheritanceHierarchy(@SpanAttribute("dataType.id") @PathVariable String id) { + log.debug("Getting inheritance hierarchy for DataType: {}", id); + try { + Object hierarchy = dataTypeService.getInheritanceHierarchy(id); + return ResponseEntity.ok(EntityModel.of(hierarchy)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/{id}/operations") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "dataTypeController.getOperations", description = "Time taken to get DataType operations", histogram = true) + @Counted(value = "dataTypeController.getOperations.count", description = "Number of DataType operations requests") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get operations for a DataType", + description = "Returns all operations that can be executed on this DataType" + ) + public ResponseEntity>> getOperations(@SpanAttribute("dataType.id") @PathVariable String id) { + log.debug("Getting operations for DataType: {}", id); + try { + List operations = dataTypeService.getOperationsForDataType(id); + + List> operationModels = operations.stream() + .map(EntityModel::of) + .toList(); + + CollectionModel> collectionModel = CollectionModel.of(operationModels); + return ResponseEntity.ok(collectionModel); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/v1/TypeProfileController.java b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/v1/TypeProfileController.java new file mode 100644 index 0000000..657d1c5 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/datatypes/web/v1/TypeProfileController.java @@ -0,0 +1,270 @@ +/* + * 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.idoris.datatypes.web.v1; + +import edu.kit.datamanager.idoris.datatypes.api.ITypeProfileExternalService; +import edu.kit.datamanager.idoris.datatypes.dto.TypeProfileDto; +import edu.kit.datamanager.idoris.datatypes.web.api.ITypeProfileApi; +import edu.kit.datamanager.idoris.datatypes.web.hateoas.TypeProfileModelAssembler; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * DTO-first REST controller for TypeProfiles. + * Provides endpoints for managing TypeProfiles using DTOs exclusively, with + * relationship link/unlink operations for inheritsFrom and attributes. + */ +@RestController +@Slf4j +@Observed(contextualName = "typeProfileController") +public class TypeProfileController implements ITypeProfileApi { + + private final ITypeProfileExternalService service; + private final TypeProfileModelAssembler assembler; + + @Autowired + public TypeProfileController(ITypeProfileExternalService service) { + this.service = service; + // Fallback assembler to avoid requiring bean in slice tests + this.assembler = new TypeProfileModelAssembler(); + } + + public TypeProfileController(ITypeProfileExternalService service, TypeProfileModelAssembler assembler) { + this.service = service; + this.assembler = assembler; + } + + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "typeProfileController.list", histogram = true) + @Counted(value = "typeProfileController.list.count") + public ResponseEntity>> getAllTypeProfiles() { + List dtos = service.list(); + return ResponseEntity.ok(assembler.toCollectionModel(dtos)); + } + + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "typeProfileController.get", histogram = true) + @Counted(value = "typeProfileController.get.count") + public ResponseEntity> getTypeProfile(@SpanAttribute("typeProfile.id") String id) { + Optional dto = service.get(id); + return dto.map(d -> ResponseEntity.ok(assembler.toModel(d))).orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping("/{id}/operations") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "typeProfileController.getOperationsForTypeProfile", histogram = true) + @Counted(value = "typeProfileController.getOperationsForTypeProfile.count") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get operations for a TypeProfile", + description = "Returns all operations that can be executed on this TypeProfile" + ) + public ResponseEntity>> getOperationsForTypeProfile(@SpanAttribute("typeProfile.id") @PathVariable String id) { + log.debug("Getting operations for TypeProfile with ID: {}", id); + if (service.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + // Get the operations available for this TypeProfile using the Operations module + List operations = service.getOperationsForTypeProfile(id); + + // Convert to EntityModel collection + List> operationModels = operations.stream() + .map(EntityModel::of) + .toList(); + + CollectionModel> collectionModel = CollectionModel.of(operationModels); + + return ResponseEntity.ok(collectionModel); + } + + @GetMapping("/{id}/inheritedAttributes") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "typeProfileController.getInheritedAttributes", histogram = true) + @Counted(value = "typeProfileController.getInheritedAttributes.count") + public ResponseEntity> getInheritedAttributes(@SpanAttribute("typeProfile.id") @PathVariable String id) { + log.debug("Getting inherited attributes for TypeProfile with ID: {}", id); + if (service.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + // Get the TypeProfile and collect all inherited attribute IDs + Optional dto = service.get(id); + if (dto.isPresent()) { + Set inheritedAttributeIds = service.getInheritedAttributes(id); + return ResponseEntity.ok(CollectionModel.of(inheritedAttributeIds)); + } + + return ResponseEntity.notFound().build(); + } + + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "typeProfileController.create", histogram = true) + @Counted(value = "typeProfileController.create.count") + public ResponseEntity> createTypeProfile(TypeProfileDto dto) { + TypeProfileDto created = service.create(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(assembler.toModel(created)); + } + + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "typeProfileController.update", histogram = true) + @Counted(value = "typeProfileController.update.count") + public ResponseEntity> updateTypeProfile(@SpanAttribute("typeProfile.id") String id, + TypeProfileDto dto) { + if (service.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + TypeProfileDto updated = service.update(id, dto); + return ResponseEntity.ok(assembler.toModel(updated)); + } + + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "typeProfileController.delete", histogram = true) + @Counted(value = "typeProfileController.delete.count") + public ResponseEntity deleteTypeProfile(@SpanAttribute("typeProfile.id") String id) { + if (service.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + service.delete(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/inheritanceTree") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "typeProfileController.getInheritanceTree", histogram = true) + @Counted(value = "typeProfileController.getInheritanceTree.count") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get inheritance tree for a TypeProfile", + description = "Returns the complete inheritance hierarchy for this TypeProfile" + ) + public ResponseEntity> getInheritanceTree(@SpanAttribute("typeProfile.id") @PathVariable String id) { + log.debug("Getting inheritance tree for TypeProfile with ID: {}", id); + if (service.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + // Get the inheritance chain/tree for the TypeProfile + Object inheritanceTree = service.getInheritanceTree(id); + EntityModel entityModel = EntityModel.of(inheritanceTree); + + return ResponseEntity.ok(entityModel); + } + + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "typeProfileController.patch", histogram = true) + @Counted(value = "typeProfileController.patch.count") + public ResponseEntity> patchTypeProfile(@SpanAttribute("typeProfile.id") String id, + TypeProfileDto dto) { + if (service.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + TypeProfileDto patched = service.patch(id, dto); + return ResponseEntity.ok(assembler.toModel(patched)); + } + + @GetMapping("/{id}/inheritsFrom") + public ResponseEntity> listInheritsFrom(@PathVariable String id) { + return service.get(id) + .map(tp -> ResponseEntity.ok(tp.getInheritsFromIds())) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping("/{id}/inheritsFrom") + public ResponseEntity> addInheritsFrom(@PathVariable String id, @RequestBody Set parentIds) { + if (service.get(id).isEmpty()) return ResponseEntity.notFound().build(); + TypeProfileDto dto = service.addInheritsFrom(id, parentIds); + return ResponseEntity.ok(assembler.toModel(dto)); + } + + @DeleteMapping("/{id}/inheritsFrom") + public ResponseEntity> deleteInheritsFrom(@PathVariable String id, @RequestBody Set parentIds) { + if (service.get(id).isEmpty()) return ResponseEntity.notFound().build(); + TypeProfileDto dto = service.removeInheritsFrom(id, parentIds); + return ResponseEntity.ok(assembler.toModel(dto)); + } + + @PutMapping("/{id}/inheritsFrom") + public ResponseEntity> setInheritsFrom(@PathVariable String id, @RequestBody Set parentIds) { + Optional currentOpt = service.get(id); + if (currentOpt.isEmpty()) return ResponseEntity.notFound().build(); + Set current = currentOpt.get().getInheritsFromIds(); + // Remove ones not in new set + if (current != null && !current.isEmpty()) { + Set toRemove = new java.util.HashSet<>(current); + toRemove.removeAll(parentIds == null ? java.util.Set.of() : parentIds); + if (!toRemove.isEmpty()) service.removeInheritsFrom(id, toRemove); + } + // Add missing ones + if (parentIds != null && !parentIds.isEmpty()) service.addInheritsFrom(id, parentIds); + return ResponseEntity.ok(assembler.toModel(service.get(id).get())); + } + + @GetMapping("/{id}/attributes") + public ResponseEntity> listAttributes(@PathVariable String id) { + return service.get(id) + .map(tp -> ResponseEntity.ok(tp.getAttributeIds())) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + // Additional endpoints for HATEOAS compatibility + + @PostMapping("/{id}/attributes") + public ResponseEntity> addAttributesRest(@PathVariable String id, @RequestBody Set attributeIds) { + if (service.get(id).isEmpty()) return ResponseEntity.notFound().build(); + TypeProfileDto dto = service.addAttributes(id, attributeIds); + return ResponseEntity.ok(assembler.toModel(dto)); + } + + @DeleteMapping("/{id}/attributes") + public ResponseEntity> deleteAttributesRest(@PathVariable String id, @RequestBody Set attributeIds) { + if (service.get(id).isEmpty()) return ResponseEntity.notFound().build(); + TypeProfileDto dto = service.removeAttributes(id, attributeIds); + return ResponseEntity.ok(assembler.toModel(dto)); + } + + @PutMapping("/{id}/attributes") + public ResponseEntity> setAttributes(@PathVariable String id, @RequestBody Set attributeIds) { + Optional currentOpt = service.get(id); + if (currentOpt.isEmpty()) return ResponseEntity.notFound().build(); + Set current = currentOpt.get().getAttributeIds(); + if (current != null && !current.isEmpty()) { + Set toRemove = new java.util.HashSet<>(current); + toRemove.removeAll(attributeIds == null ? java.util.Set.of() : attributeIds); + if (!toRemove.isEmpty()) service.removeAttributes(id, toRemove); + } + if (attributeIds != null && !attributeIds.isEmpty()) service.addAttributes(id, attributeIds); + return ResponseEntity.ok(assembler.toModel(service.get(id).get())); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/api/IOperationExternalService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/api/IOperationExternalService.java new file mode 100644 index 0000000..0c04e8b --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/api/IOperationExternalService.java @@ -0,0 +1,44 @@ +/* + * 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.idoris.operations.api; + +import edu.kit.datamanager.idoris.operations.dto.OperationRequestDto; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import edu.kit.datamanager.idoris.rules.validation.ValidationResult; + +import java.util.List; +import java.util.Optional; + +/** + * External API for Operation (DTO-first) used by controllers and other modules. + */ +public interface IOperationExternalService { + OperationResponseDto create(OperationRequestDto dto); + + OperationResponseDto update(String id, OperationRequestDto dto); + + OperationResponseDto patch(String id, OperationRequestDto dto); + + void delete(String id); + + Optional get(String id); + + List list(); + + List getOperationsForDataType(String dataTypeId); + + ValidationResult validate(String id); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/api/IOperationManagementExternalService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/api/IOperationManagementExternalService.java new file mode 100644 index 0000000..5e40616 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/api/IOperationManagementExternalService.java @@ -0,0 +1,50 @@ +/* + * 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.idoris.operations.api; + +import edu.kit.datamanager.idoris.operations.dto.OperationStepDto; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +/** + * External API for managing Operation steps and their attribute mappings. + */ +public interface IOperationManagementExternalService { + // Steps + List listSteps(String operationId); + + OperationStepDto createStep(String operationId, OperationStepDto step); + + void removeSteps(String operationId, Set stepIds); + + void linkExistingSteps(String operationId, Collection stepIds); + + // Input mappings + List listInputMappings(String stepId); + + void addInputMappings(String stepId, Set mappingIds); + + void removeInputMappings(String stepId, Set mappingIds); + + // Output mappings + List listOutputMappings(String stepId); + + void addOutputMappings(String stepId, Set mappingIds); + + void removeOutputMappings(String stepId, Set mappingIds); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/api/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/api/package-info.java new file mode 100644 index 0000000..c89b462 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/api/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("operations.services.api") +package edu.kit.datamanager.idoris.operations.api; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IAttributeMappingDao.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IAttributeMappingDao.java new file mode 100644 index 0000000..256af5e --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IAttributeMappingDao.java @@ -0,0 +1,154 @@ +/* + * 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.idoris.operations.dao; + +import edu.kit.datamanager.idoris.core.domain.valueObjects.AttributeMapping; +import org.springframework.data.neo4j.repository.Neo4jRepository; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.data.repository.query.Param; + +/** + * Repository interface for AttributeMapping entities. + */ +public interface IAttributeMappingDao extends Neo4jRepository { + + /** + * Link input Attribute to an AttributeMapping by IDs (PID or internal ID for attribute). + */ + @Query(""" + MATCH (m:AttributeMapping {internalId: $mappingId}) + MATCH (a:Attribute) + WHERE a.internalId = $attributeId OR EXISTS { MATCH (pid:PersistentIdentifier {pid: $attributeId})-[:IDENTIFIES]->(a) } + MERGE (a)-[:input]->(m) + """) + void linkInputAttribute(@Param("mappingId") String mappingId, @Param("attributeId") String attributeId); + + /** + * Link output Attribute to an AttributeMapping by IDs (PID or internal ID for attribute). + */ + @Query(""" + MATCH (m:AttributeMapping {internalId: $mappingId}) + MATCH (a:Attribute) + WHERE a.internalId = $attributeId OR EXISTS { MATCH (pid:PersistentIdentifier {pid: $attributeId})-[:IDENTIFIES]->(a) } + MERGE (m)-[:output]->(a) + """) + void linkOutputAttribute(@Param("mappingId") String mappingId, @Param("attributeId") String attributeId); + + /** + * Finds AttributeMapping entities by input attribute ID (either PID or internal ID). + * This method tries to find mappings using both PID and internal ID. + * + * @param id the ID of the input attribute (either PID or internal ID) + * @return an Iterable of AttributeMapping entities + */ + default Iterable findByInputAttributeId(String id) { + // Try both PID and internal ID + Iterable byPid = findByInputAttributePid(id); + Iterable byInternalId = findByInputAttributeInternalId(id); + + // Combine the results + return () -> { + java.util.Iterator pidIterator = byPid.iterator(); + java.util.Iterator internalIdIterator = byInternalId.iterator(); + + return new java.util.Iterator() { + @Override + public boolean hasNext() { + return pidIterator.hasNext() || internalIdIterator.hasNext(); + } + + @Override + public AttributeMapping next() { + if (pidIterator.hasNext()) { + return pidIterator.next(); + } + return internalIdIterator.next(); + } + }; + }; + } + + /** + * Finds AttributeMapping entities by input attribute PID. + * + * @param pid the PID of the input attribute + * @return an Iterable of AttributeMapping entities + */ + @Query("MATCH (a:Attribute {pid: $pid})<-[:input]-(m:AttributeMapping) RETURN m") + Iterable findByInputAttributePid(String pid); + + /** + * Finds AttributeMapping entities by input attribute internal ID. + * + * @param internalId the internal ID of the input attribute + * @return an Iterable of AttributeMapping entities + */ + @Query("MATCH (a:Attribute {internalId: $internalId})<-[:input]-(m:AttributeMapping) RETURN m") + Iterable findByInputAttributeInternalId(String internalId); + + /** + * Finds AttributeMapping entities by output attribute ID (either PID or internal ID). + * This method tries to find mappings using both PID and internal ID. + * + * @param id the ID of the output attribute (either PID or internal ID) + * @return an Iterable of AttributeMapping entities + */ + default Iterable findByOutputAttributeId(String id) { + // Try both PID and internal ID + Iterable byPid = findByOutputAttributePid(id); + Iterable byInternalId = findByOutputAttributeInternalId(id); + + // Combine the results + return () -> { + java.util.Iterator pidIterator = byPid.iterator(); + java.util.Iterator internalIdIterator = byInternalId.iterator(); + + return new java.util.Iterator() { + @Override + public boolean hasNext() { + return pidIterator.hasNext() || internalIdIterator.hasNext(); + } + + @Override + public AttributeMapping next() { + if (pidIterator.hasNext()) { + return pidIterator.next(); + } + return internalIdIterator.next(); + } + }; + }; + } + + /** + * Finds AttributeMapping entities by output attribute PID. + * + * @param pid the PID of the output attribute + * @return an Iterable of AttributeMapping entities + */ + @Query("MATCH (a:Attribute {pid: $pid})<-[:output]-(m:AttributeMapping) RETURN m") + Iterable findByOutputAttributePid(String pid); + + /** + * Finds AttributeMapping entities by output attribute internal ID. + * + * @param internalId the internal ID of the output attribute + * @return an Iterable of AttributeMapping entities + */ + @Query("MATCH (a:Attribute {internalId: $internalId})<-[:output]-(m:AttributeMapping) RETURN m") + Iterable findByOutputAttributeInternalId(String internalId); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IOperationDao.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IOperationDao.java new file mode 100644 index 0000000..a47c3fa --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IOperationDao.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2024-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.idoris.operations.dao; + +import edu.kit.datamanager.idoris.core.dao.IGenericRepo; +import edu.kit.datamanager.idoris.core.domain.Operation; +import org.springframework.data.neo4j.repository.query.Query; + +/** + * Repository interface for Operation entities. + */ +public interface IOperationDao extends IGenericRepo { + /** + * Gets operations that can be executed on a data type. + * This method finds operations that are executable on the given data type or its attributes. + * This method tries to find operations using both PID and internal ID. + * + * @param id the ID of the data type (either PID or internal ID) + * @return an Iterable of Operation entities + */ + default Iterable getOperationsForDataType(String id) { + // Try both PID and internal ID + Iterable byPid = getOperationsForDataTypeByPid(id); + Iterable byInternalId = getOperationsForDataTypeByInternalId(id); + + // Combine the results + return () -> { + java.util.Iterator pidIterator = byPid.iterator(); + java.util.Iterator internalIdIterator = byInternalId.iterator(); + + return new java.util.Iterator() { + @Override + public boolean hasNext() { + return pidIterator.hasNext() || internalIdIterator.hasNext(); + } + + @Override + public Operation next() { + if (pidIterator.hasNext()) { + return pidIterator.next(); + } + return internalIdIterator.next(); + } + }; + }; + } + + /** + * Gets operations that can be executed on a data type. + * This method finds operations that are executable on the given data type or its attributes. + * + * @param pid the PID of the data type + * @return an Iterable of Operation entities + */ + @Query(""" + MATCH (d:DataType {pid: $pid})<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) RETURN o + UNION + MATCH (d:DataType {pid: $pid})-[:attributes]->(:Attribute)<-[:executableOn]-(o:Operation) RETURN o + UNION + MATCH (d:DataType {pid: $pid})-[:attributes]->(:Attribute)-[:dataType]->(:DataType)<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) RETURN o + UNION + MATCH (d:DataType {pid: $pid})-[:inheritsFrom*]->(:DataType)-[:attributes]->(:Attribute)-[:dataType]->(:DataType)<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) RETURN o + UNION + MATCH (d:DataType {pid: $pid})-[:inheritsFrom*]->(:DataType)-[:attributes]->(:Attribute)-[:dataType]->(:DataType)-[:inheritsFrom*]->(:DataType)<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) RETURN o""") + Iterable getOperationsForDataTypeByPid(String pid); + + /** + * Gets operations that can be executed on a data type. + * This method finds operations that are executable on the given data type or its attributes. + * + * @param internalId the internal ID of the data type + * @return an Iterable of Operation entities + */ + @Query(""" + MATCH (d:DataType {internalId: $internalId})<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) RETURN o + UNION + MATCH (d:DataType {internalId: $internalId})-[:attributes]->(:Attribute)<-[:executableOn]-(o:Operation) RETURN o + UNION + MATCH (d:DataType {internalId: $internalId})-[:attributes]->(:Attribute)-[:dataType]->(:DataType)<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) RETURN o + UNION + MATCH (d:DataType {internalId: $internalId})-[:inheritsFrom*]->(:DataType)-[:attributes]->(:Attribute)-[:dataType]->(:DataType)<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) RETURN o + UNION + MATCH (d:DataType {internalId: $internalId})-[:inheritsFrom*]->(:DataType)-[:attributes]->(:Attribute)-[:dataType]->(:DataType)-[:inheritsFrom*]->(:DataType)<-[:dataType]-(:Attribute)<-[:executableOn]-(o:Operation) RETURN o""") + Iterable getOperationsForDataTypeByInternalId(String internalId); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IOperationRelationshipDao.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IOperationRelationshipDao.java new file mode 100644 index 0000000..3d0c6a7 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IOperationRelationshipDao.java @@ -0,0 +1,125 @@ +/* + * 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.idoris.operations.dao; + +import edu.kit.datamanager.idoris.core.domain.valueObjects.AttributeMapping; +import edu.kit.datamanager.idoris.core.domain.valueObjects.OperationStep; +import org.springframework.data.neo4j.repository.Neo4jRepository; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.data.repository.query.Param; + +/** + * DAO for managing relationships between Operation, OperationStep, and AttributeMapping. + */ +public interface IOperationRelationshipDao extends Neo4jRepository { + + // Steps for an Operation + @Query(""" + MATCH (o:Operation) + WHERE o.internalId = $opId OR EXISTS { MATCH (pid:PersistentIdentifier {pid: $opId})-[:IDENTIFIES]->(o) } + MATCH (o)-[:execution]->(s:OperationStep) + RETURN s ORDER BY s.index + """) + Iterable listSteps(@Param("opId") String operationId); + + @Query(""" + MATCH (o:Operation) + WHERE o.internalId = $opId OR EXISTS { MATCH (pid:PersistentIdentifier {pid: $opId})-[:IDENTIFIES]->(o) } + MATCH (s:OperationStep {internalId: $stepId}) + MERGE (o)-[:execution]->(s) + """) + void addStep(@Param("opId") String operationId, @Param("stepId") String stepId); + + @Query(""" + MATCH (o:Operation) + WHERE o.internalId = $opId OR EXISTS { MATCH (pid:PersistentIdentifier {pid: $opId})-[:IDENTIFIES]->(o) } + MATCH (s:OperationStep) + WHERE s.internalId IN $stepIds + MATCH (o)-[r:execution]->(s) + DELETE r + """) + void removeSteps(@Param("opId") String operationId, @Param("stepIds") java.util.Collection stepIds); + + // Sub-steps relationship + @Query(""" + MATCH (p:OperationStep {internalId: $parentId}) + MATCH (c:OperationStep {internalId: $childId}) + MERGE (p)-[:subSteps]->(c) + """) + void addSubStep(@Param("parentId") String parentStepId, @Param("childId") String childStepId); + + // ExecuteOperation link + @Query(""" + MATCH (s:OperationStep {internalId: $stepId}) + MATCH (o:Operation) + WHERE o.internalId = $opId OR EXISTS { MATCH (pid:PersistentIdentifier {pid: $opId})-[:IDENTIFIES]->(o) } + MERGE (s)-[:executeOperation]->(o) + """) + void setStepExecuteOperation(@Param("stepId") String stepId, @Param("opId") String opId); + + // UseTechnology link + @Query(""" + MATCH (s:OperationStep {internalId: $stepId}) + MATCH (t:TechnologyInterface) + WHERE t.internalId = $techId OR EXISTS { MATCH (pid:PersistentIdentifier {pid: $techId})-[:IDENTIFIES]->(t) } + MERGE (s)-[:useTechnology]->(t) + """) + void setStepUseTechnology(@Param("stepId") String stepId, @Param("techId") String technologyId); + + // Input mappings of a step + @Query(""" + MATCH (m:AttributeMapping)-[:inputMappings]->(s:OperationStep {internalId: $stepId}) + RETURN m ORDER BY m.index + """) + Iterable listInputMappings(@Param("stepId") String stepId); + + @Query(""" + UNWIND $mappingIds AS mid + MATCH (m:AttributeMapping {internalId: mid}) + MATCH (s:OperationStep {internalId: $stepId}) + MERGE (m)-[:inputMappings]->(s) + """) + void addInputMappings(@Param("stepId") String stepId, @Param("mappingIds") java.util.Collection mappingIds); + + @Query(""" + MATCH (m:AttributeMapping)-[r:inputMappings]->(s:OperationStep {internalId: $stepId}) + WHERE m.internalId IN $mappingIds + DELETE r + """) + void removeInputMappings(@Param("stepId") String stepId, @Param("mappingIds") java.util.Collection mappingIds); + + // Output mappings of a step + @Query(""" + MATCH (s:OperationStep {internalId: $stepId})-[:outputMappings]->(m:AttributeMapping) + RETURN m ORDER BY m.index + """) + Iterable listOutputMappings(@Param("stepId") String stepId); + + @Query(""" + UNWIND $mappingIds AS mid + MATCH (m:AttributeMapping {internalId: mid}) + MATCH (s:OperationStep {internalId: $stepId}) + MERGE (s)-[:outputMappings]->(m) + """) + void addOutputMappings(@Param("stepId") String stepId, @Param("mappingIds") java.util.Collection mappingIds); + + @Query(""" + MATCH (s:OperationStep {internalId: $stepId})-[r:outputMappings]->(m:AttributeMapping) + WHERE m.internalId IN $mappingIds + DELETE r + """) + void removeOutputMappings(@Param("stepId") String stepId, @Param("mappingIds") java.util.Collection mappingIds); +} diff --git a/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecord.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IOperationStepDao.java similarity index 61% rename from src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecord.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IOperationStepDao.java index 3080d56..fded5a7 100644 --- a/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecord.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dao/IOperationStepDao.java @@ -13,17 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package edu.kit.datamanager.idoris.operations.dao; -package edu.kit.datamanager.idoris.pids.client.model; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -import java.util.List; +import edu.kit.datamanager.idoris.core.domain.valueObjects.OperationStep; +import org.springframework.data.neo4j.repository.Neo4jRepository; /** - * Represents a Simple PID Record in the Typed PID Maker service. - * This follows the SimplePidRecord structure from the Typed PID Maker API. + * Repository for OperationStep entities. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public record PIDRecord(String pid, List record) { +public interface IOperationStepDao extends Neo4jRepository { } diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/AttributeMappingDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/AttributeMappingDto.java new file mode 100644 index 0000000..fffdc80 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/AttributeMappingDto.java @@ -0,0 +1,62 @@ +/* + * 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.idoris.operations.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +/** + * DTO for AttributeMapping used inside OperationStep nested creation. + */ +@Builder +@Schema(name = "AttributeMapping", description = "Attribute mapping definition for a step") +public record AttributeMappingDto( + @Schema(description = "Internal ID (server-managed)") String internalId, + @Schema(description = "Name of the mapping") String name, + @Schema(description = "PID or ID of input Attribute") String inputAttributeId, + @Schema(description = "Replacement template / expression") String replaceCharactersInValueWithInput, + @Schema(description = "Static value (optional)") String value, + @Schema(description = "Index for ordering") Integer index, + @Schema(description = "PID or ID of output Attribute") String outputAttributeId +) { + public String getInternalId() { + return internalId; + } + + public String getName() { + return name; + } + + public String getInputAttributeId() { + return inputAttributeId; + } + + public String getReplaceCharactersInValueWithInput() { + return replaceCharactersInValueWithInput; + } + + public String getValue() { + return value; + } + + public Integer getIndex() { + return index; + } + + public String getOutputAttributeId() { + return outputAttributeId; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationDto.java new file mode 100644 index 0000000..b9e8f7a --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationDto.java @@ -0,0 +1,71 @@ +/* + * 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.idoris.operations.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; +import java.util.Set; + +/** + * DTO for Operation exposing user-defined fields and relationship IDs only. + */ +@Builder +@Schema(name = "Operation", description = "Operation resource") +public record OperationDto( + @Schema(description = "Internal ID (server-managed)") String internalId, + @Schema(description = "Name") String name, + @Schema(description = "Description") String description, + @Schema(description = "ID of Attribute this operation can execute on") String executableOnAttributeId, + @Schema(description = "IDs of return Attributes") Set returnAttributeIds, + @Schema(description = "IDs of environment Attributes") Set environmentAttributeIds, + @Schema(description = "IDs of execution step nodes (if any)") List executionStepIds, + @Schema(description = "Full execution step definitions for nested creation (optional)") List executionSteps +) { + // JavaBean getters for compatibility + public String getInternalId() { + return internalId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getExecutableOnAttributeId() { + return executableOnAttributeId; + } + + public Set getReturnAttributeIds() { + return returnAttributeIds; + } + + public Set getEnvironmentAttributeIds() { + return environmentAttributeIds; + } + + public List getExecutionStepIds() { + return executionStepIds; + } + + public List getExecutionSteps() { + return executionSteps; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationRequestDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationRequestDto.java new file mode 100644 index 0000000..e4f807c --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationRequestDto.java @@ -0,0 +1,64 @@ +/* + * 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.idoris.operations.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; +import java.util.Set; + +/** + * Request DTO for creating/updating Operations. Contains only client-supplied fields. + */ +@Schema(name = "OperationRequest", description = "Operation request payload") +public record OperationRequestDto( + @Schema(description = "Name") String name, + @Schema(description = "Description") String description, + @Schema(description = "ID of Attribute this operation can execute on") String executableOnAttributeId, + @Schema(description = "IDs of return Attributes") Set returnAttributeIds, + @Schema(description = "IDs of environment Attributes") Set environmentAttributeIds, + @Schema(description = "IDs of execution step nodes (if any)") List executionStepIds, + @Schema(description = "Full execution step definitions for nested creation (optional)") List executionSteps +) { + // JavaBean-style accessors for frameworks that require them + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getExecutableOnAttributeId() { + return executableOnAttributeId; + } + + public Set getReturnAttributeIds() { + return returnAttributeIds; + } + + public Set getEnvironmentAttributeIds() { + return environmentAttributeIds; + } + + public List getExecutionStepIds() { + return executionStepIds; + } + + public List getExecutionSteps() { + return executionSteps; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationResponseDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationResponseDto.java new file mode 100644 index 0000000..cae1a5f --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationResponseDto.java @@ -0,0 +1,88 @@ +/* + * 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.idoris.operations.dto; + +import edu.kit.datamanager.idoris.core.AdministrativeMetadataDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +/** + * Response DTO for Operation. Extends the administrative metadata contract. + */ +@Builder +@Schema(name = "OperationResponse", description = "Operation response resource") +public record OperationResponseDto( + @Schema(description = "Internal ID (server-managed)") String internalId, + @Schema(description = "Entity version") Long version, + @Schema(description = "Creation timestamp") Instant createdAt, + @Schema(description = "Last modification timestamp") Instant lastModifiedAt, + @Schema(description = "Name") String name, + @Schema(description = "Description") String description, + @Schema(description = "ID of Attribute this operation can execute on") String executableOnAttributeId, + @Schema(description = "IDs of return Attributes") Set returnAttributeIds, + @Schema(description = "IDs of environment Attributes") Set environmentAttributeIds, + @Schema(description = "IDs of execution step nodes (if any)") List executionStepIds, + @Schema(description = "Full execution step definitions for nested creation (optional)") List executionSteps +) implements AdministrativeMetadataDto { + // JavaBean getters for compatibility + public String getInternalId() { + return internalId; + } + + public Long getVersion() { + return version; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getLastModifiedAt() { + return lastModifiedAt; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getExecutableOnAttributeId() { + return executableOnAttributeId; + } + + public Set getReturnAttributeIds() { + return returnAttributeIds; + } + + public Set getEnvironmentAttributeIds() { + return environmentAttributeIds; + } + + public List getExecutionStepIds() { + return executionStepIds; + } + + public List getExecutionSteps() { + return executionSteps; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationStepDto.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationStepDto.java new file mode 100644 index 0000000..730dc93 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/OperationStepDto.java @@ -0,0 +1,76 @@ +/* + * 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.idoris.operations.dto; + +import edu.kit.datamanager.idoris.core.domain.enums.ExecutionMode; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +/** + * DTO for OperationStep used for nested creation and listing. + */ +@Builder +@Schema(name = "OperationStep", description = "Operation step definition") +public record OperationStepDto( + @Schema(description = "Internal ID (server-managed)") String internalId, + @Schema(description = "Index (order) of the step within an Operation") Integer index, + @Schema(description = "Name of the step") String name, + @Schema(description = "Execution mode") ExecutionMode mode, + @Schema(description = "PID or ID of an Operation executed by this step (optional)") String executeOperationId, + @Schema(description = "PID or ID of TechnologyInterface used by this step (optional)") String useTechnologyId, + @Schema(description = "Input AttributeMappings") List inputMappings, + @Schema(description = "Output AttributeMappings") List outputMappings, + @Schema(description = "Nested sub-steps") List subSteps +) { + // JavaBean getters for compatibility + public String getInternalId() { + return internalId; + } + + public Integer getIndex() { + return index; + } + + public String getName() { + return name; + } + + public ExecutionMode getMode() { + return mode; + } + + public String getExecuteOperationId() { + return executeOperationId; + } + + public String getUseTechnologyId() { + return useTechnologyId; + } + + public List getInputMappings() { + return inputMappings; + } + + public List getOutputMappings() { + return outputMappings; + } + + public List getSubSteps() { + return subSteps; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/package-info.java new file mode 100644 index 0000000..82d4556 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/dto/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("dto") +package edu.kit.datamanager.idoris.operations.dto; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationCreatedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationCreatedEvent.java new file mode 100644 index 0000000..bbd6649 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationCreatedEvent.java @@ -0,0 +1,35 @@ +/* + * 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.idoris.operations.events; + +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class OperationCreatedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the Operation") + private final String id; + private final OperationResponseDto payload; + + public OperationCreatedEvent(String id, OperationResponseDto payload) { + this.id = id; + this.payload = payload; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationDeletedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationDeletedEvent.java new file mode 100644 index 0000000..d44f9a5 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationDeletedEvent.java @@ -0,0 +1,35 @@ +/* + * 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.idoris.operations.events; + +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class OperationDeletedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the Operation") + private final String id; + private final OperationResponseDto payload; + + public OperationDeletedEvent(String id, OperationResponseDto payload) { + this.id = id; + this.payload = payload; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationPatchedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationPatchedEvent.java new file mode 100644 index 0000000..e0fee7e --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationPatchedEvent.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.idoris.operations.events; + +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class OperationPatchedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the Operation") + private final String id; + private final Long previousVersion; + private final OperationResponseDto payload; + + public OperationPatchedEvent(String id, Long previousVersion, OperationResponseDto payload) { + this.id = id; + this.previousVersion = previousVersion; + this.payload = payload; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationUpdatedEvent.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationUpdatedEvent.java new file mode 100644 index 0000000..0adaec2 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/OperationUpdatedEvent.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.idoris.operations.events; + +import edu.kit.datamanager.idoris.core.events.AbstractDomainEvent; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class OperationUpdatedEvent extends AbstractDomainEvent { + @Schema(description = "ID (PID or internalId) of the Operation") + private final String id; + private final Long previousVersion; + private final OperationResponseDto payload; + + public OperationUpdatedEvent(String id, Long previousVersion, OperationResponseDto payload) { + this.id = id; + this.previousVersion = previousVersion; + this.payload = payload; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/package-info.java new file mode 100644 index 0000000..ef785d1 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/events/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("events") +package edu.kit.datamanager.idoris.operations.events; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/mappers/OperationMapper.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/mappers/OperationMapper.java new file mode 100644 index 0000000..eef8258 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/mappers/OperationMapper.java @@ -0,0 +1,82 @@ +/* + * 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.idoris.operations.mappers; + +import edu.kit.datamanager.idoris.core.domain.Operation; +import edu.kit.datamanager.idoris.core.domain.valueObjects.Name; +import edu.kit.datamanager.idoris.operations.dto.OperationRequestDto; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import io.micrometer.observation.annotation.Observed; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Observed(contextualName = "operationMapper") +@org.springframework.stereotype.Component +public class OperationMapper { + + public OperationResponseDto toResponseDto(Operation entity) { + if (entity == null) return null; + String executableOnId = entity.getExecutableOn() != null ? entity.getExecutableOn().getId() : null; + Set returnIds = entity.getReturns() == null ? Collections.emptySet() : entity.getReturns().stream() + .filter(Objects::nonNull) + .map(a -> a.getId()) + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableSet()); + Set envIds = entity.getEnvironment() == null ? Collections.emptySet() : entity.getEnvironment().stream() + .filter(Objects::nonNull) + .map(a -> a.getId()) + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableSet()); + // Execution steps are complex; expose only IDs if present + java.util.List stepIds = entity.getExecution() == null ? java.util.List.of() : entity.getExecution().stream() + .filter(Objects::nonNull) + .map(s -> s.getId()) + .filter(Objects::nonNull) + .toList(); + return OperationResponseDto.builder() + .internalId(entity.getId()) + .version(entity.getVersion()) + .createdAt(entity.getCreatedAt()) + .lastModifiedAt(entity.getLastModifiedAt()) + .name(entity.getName().toString()) + .description(entity.getDescription().toString()) + .executableOnAttributeId(executableOnId) + .returnAttributeIds(returnIds) + .environmentAttributeIds(envIds) + .executionStepIds(stepIds) + .build(); + } + + public Operation toEntity(OperationRequestDto dto) { + if (dto == null) return null; + Operation entity = new Operation(); + entity.setName(new Name(dto.getName())); + entity.setDescription(new Name(dto.getDescription())); + // Relationship linking by IDs is handled in DAO/logic layer if needed + return entity; + } + + public Operation applyPatch(OperationRequestDto dto, Operation entity) { + if (dto == null || entity == null) return entity; + if (dto.getName() != null) entity.setName(new Name(dto.getName())); + if (dto.getDescription() != null) entity.setDescription(new Name(dto.getDescription())); + // Relationship IDs handled elsewhere + return entity; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/package-info.java new file mode 100644 index 0000000..884616b --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/package-info.java @@ -0,0 +1,28 @@ +/* + * 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. + */ + +/** + * Operations module for IDORIS. + * This module contains entity definitions, domain services, and business logic related to operations. + * It is responsible for managing operations and operation steps. + * + *

    The Operations module depends on the core module.

    + */ +@org.springframework.modulith.ApplicationModule( + displayName = "IDORIS Operations", + allowedDependencies = {"core", "pids :: api"} +) +package edu.kit.datamanager.idoris.operations; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/AttributeMappingService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/AttributeMappingService.java new file mode 100644 index 0000000..9ed8640 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/AttributeMappingService.java @@ -0,0 +1,158 @@ +/* + * 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.idoris.operations.services; + +import edu.kit.datamanager.idoris.core.domain.valueObjects.AttributeMapping; +import edu.kit.datamanager.idoris.core.events.EventPublisherService; +import edu.kit.datamanager.idoris.operations.dao.IAttributeMappingDao; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Service for managing AttributeMapping entities. + * This logic provides methods for creating, updating, and retrieving AttributeMapping entities. + * It publishes domain events when entities are created, updated, or deleted. + */ +@Service +@Slf4j +public class AttributeMappingService { + private final IAttributeMappingDao attributeMappingDao; + private final EventPublisherService eventPublisher; + + /** + * Creates a new AttributeMappingService with the given dependencies. + * + * @param attributeMappingDao the AttributeMapping repository + * @param eventPublisher the event publisher logic + */ + public AttributeMappingService(IAttributeMappingDao attributeMappingDao, EventPublisherService eventPublisher) { + this.attributeMappingDao = attributeMappingDao; + this.eventPublisher = eventPublisher; + } + + /** + * Creates a new AttributeMapping entity. + * + * @param attributeMapping the AttributeMapping entity to create + * @return the created AttributeMapping entity + */ + @Transactional + public AttributeMapping createAttributeMapping(AttributeMapping attributeMapping) { + log.debug("Creating AttributeMapping: {}", attributeMapping); + AttributeMapping saved = attributeMappingDao.save(attributeMapping); + // AttributeMapping is not an AdministrativeMetadata aggregate; no module-scoped entity event published here. + log.info("Created AttributeMapping with ID: {}", saved.getId()); + return saved; + } + + /** + * Updates an existing AttributeMapping entity. + * + * @param attributeMapping the AttributeMapping entity to update + * @return the updated AttributeMapping entity + * @throws IllegalArgumentException if the AttributeMapping does not exist + */ + @Transactional + public AttributeMapping updateAttributeMapping(AttributeMapping attributeMapping) { + log.debug("Updating AttributeMapping: {}", attributeMapping); + + if (attributeMapping.getId() == null || attributeMapping.getId().isEmpty()) { + throw new IllegalArgumentException("AttributeMapping must have an ID to be updated"); + } + + // Check if the entity exists + if (!attributeMappingDao.existsById(attributeMapping.getId())) { + throw new IllegalArgumentException("AttributeMapping not found with ID: " + attributeMapping.getId()); + } + + AttributeMapping saved = attributeMappingDao.save(attributeMapping); + // No module-scoped event for AttributeMapping updates (non-aggregate) + log.info("Updated AttributeMapping with ID: {}", saved.getId()); + return saved; + } + + /** + * Deletes an AttributeMapping entity. + * + * @param id the ID of the AttributeMapping to delete + * @throws IllegalArgumentException if the AttributeMapping does not exist + */ + @Transactional + public void deleteAttributeMapping(String id) { + log.debug("Deleting AttributeMapping with ID: {}", id); + + AttributeMapping attributeMapping = attributeMappingDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("AttributeMapping not found with ID: " + id)); + + attributeMappingDao.delete(attributeMapping); + // No module-scoped event for AttributeMapping deletions (non-aggregate) + log.info("Deleted AttributeMapping with ID: {}", id); + } + + /** + * Retrieves an AttributeMapping entity by its ID. + * + * @param id the ID of the AttributeMapping to retrieve + * @return an Optional containing the AttributeMapping, or empty if not found + */ + @Transactional(readOnly = true) + public Optional getAttributeMapping(String id) { + log.debug("Retrieving AttributeMapping with ID: {}", id); + return attributeMappingDao.findById(id); + } + + /** + * Retrieves all AttributeMapping entities. + * + * @return a list of all AttributeMapping entities + */ + @Transactional(readOnly = true) + public List getAllAttributeMappings() { + log.debug("Retrieving all AttributeMappings"); + return attributeMappingDao.findAll(); + } + + + /** + * Finds AttributeMapping entities by input attribute ID (either PID or internal ID). + * + * @param id the ID of the input attribute (either PID or internal ID) + * @return a list of AttributeMapping entities + */ + @Transactional(readOnly = true) + public List findByInputAttributeId(String id) { + log.debug("Finding AttributeMappings by input attribute ID: {}", id); + return (List) attributeMappingDao.findByInputAttributeId(id); + } + + + /** + * Finds AttributeMapping entities by output attribute ID (either PID or internal ID). + * + * @param id the ID of the output attribute (either PID or internal ID) + * @return a list of AttributeMapping entities + */ + @Transactional(readOnly = true) + public List findByOutputAttributeId(String id) { + log.debug("Finding AttributeMappings by output attribute ID: {}", id); + return (List) attributeMappingDao.findByOutputAttributeId(id); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/OperationDtoService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/OperationDtoService.java new file mode 100644 index 0000000..433693b --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/OperationDtoService.java @@ -0,0 +1,215 @@ +/* + * 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.idoris.operations.services; + +import edu.kit.datamanager.idoris.core.domain.Operation; +import edu.kit.datamanager.idoris.core.domain.valueObjects.AttributeMapping; +import edu.kit.datamanager.idoris.core.domain.valueObjects.OperationStep; +import edu.kit.datamanager.idoris.operations.api.IOperationExternalService; +import edu.kit.datamanager.idoris.operations.dao.IAttributeMappingDao; +import edu.kit.datamanager.idoris.operations.dao.IOperationDao; +import edu.kit.datamanager.idoris.operations.dao.IOperationRelationshipDao; +import edu.kit.datamanager.idoris.operations.dao.IOperationStepDao; +import edu.kit.datamanager.idoris.operations.dto.AttributeMappingDto; +import edu.kit.datamanager.idoris.operations.dto.OperationRequestDto; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import edu.kit.datamanager.idoris.operations.dto.OperationStepDto; +import edu.kit.datamanager.idoris.operations.events.OperationCreatedEvent; +import edu.kit.datamanager.idoris.operations.events.OperationDeletedEvent; +import edu.kit.datamanager.idoris.operations.events.OperationPatchedEvent; +import edu.kit.datamanager.idoris.operations.events.OperationUpdatedEvent; +import edu.kit.datamanager.idoris.operations.mappers.OperationMapper; +import edu.kit.datamanager.idoris.rules.validation.ValidationPolicyValidator; +import edu.kit.datamanager.idoris.rules.validation.ValidationResult; +import io.micrometer.observation.annotation.Observed; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@Slf4j +@Observed(contextualName = "operationDtoService") +@RequiredArgsConstructor +class OperationDtoService implements IOperationExternalService { + + private final OperationService operationService; + private final IOperationDao operationDao; + private final OperationMapper mapper; + private final IOperationStepDao stepDao; + private final IOperationRelationshipDao relDao; + private final IAttributeMappingDao attributeMappingDao; + private final edu.kit.datamanager.idoris.core.events.EventPublisherService eventPublisher; + + @Override + @Transactional + public OperationResponseDto create(OperationRequestDto dto) { + log.debug("Creating Operation DTO: {}", dto.getName()); + Operation entity = mapper.toEntity(dto); + Operation saved = operationService.createOperation(entity); + + // Nested creation: steps and mappings + if (dto.getExecutionSteps() != null && !dto.getExecutionSteps().isEmpty()) { + for (OperationStepDto stepDto : dto.getExecutionSteps()) { + createStepRecursive(saved.getId(), null, stepDto); + } + } + + Operation reloaded = operationDao.findById(saved.getId()).orElse(saved); + OperationResponseDto createdDto = mapper.toResponseDto(reloaded); + eventPublisher.publishEvent(new OperationCreatedEvent(reloaded.getId(), createdDto)); + return createdDto; + } + + @Override + @Transactional + public OperationResponseDto update(String id, OperationRequestDto dto) { + Operation existing = operationDao.findById(id).orElseThrow(() -> new IllegalArgumentException("Operation not found: " + id)); + Long previousVersion = existing.getVersion(); + existing = mapper.applyPatch(dto, existing); + Operation saved = operationService.updateOperation(existing); + Operation reloaded = operationDao.findById(saved.getId()).orElse(saved); + OperationResponseDto updatedDto = mapper.toResponseDto(reloaded); + eventPublisher.publishEvent(new OperationUpdatedEvent(reloaded.getId(), previousVersion, updatedDto)); + return updatedDto; + } + + @Override + @Transactional + public OperationResponseDto patch(String id, OperationRequestDto dto) { + Operation existing = operationDao.findById(id).orElseThrow(() -> new IllegalArgumentException("Operation not found: " + id)); + Long previousVersion = existing.getVersion(); + existing = mapper.applyPatch(dto, existing); + Operation saved = operationService.patchOperation(id, existing); + Operation reloaded = operationDao.findById(saved.getId()).orElse(saved); + OperationResponseDto patchedDto = mapper.toResponseDto(reloaded); + eventPublisher.publishEvent(new OperationPatchedEvent(reloaded.getId(), previousVersion, patchedDto)); + return patchedDto; + } + + @Override + @Transactional + public void delete(String id) { + Optional existing = operationDao.findById(id); + OperationResponseDto payload = existing.map(mapper::toResponseDto).orElse(null); + operationService.deleteOperation(id); + if (payload != null) { + eventPublisher.publishEvent(new OperationDeletedEvent(id, payload)); + } + } + + @Override + @Transactional(readOnly = true) + public Optional get(String id) { + return operationService.getOperation(id).map(mapper::toResponseDto); + } + + @Override + @Transactional(readOnly = true) + public List list() { + return operationService.getAllOperations().stream().map(mapper::toResponseDto).toList(); + } + + @Override + @Transactional(readOnly = true) + public List getOperationsForDataType(String dataTypeId) { + Iterable ops = operationService.getOperationsForDataType(dataTypeId); + return java.util.stream.StreamSupport.stream(ops.spliterator(), false) + .map(mapper::toResponseDto) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public ValidationResult validate(String id) { + Operation op = operationService.getOperation(id).orElseThrow(() -> new IllegalArgumentException("Operation not found: " + id)); + ValidationPolicyValidator validator = new ValidationPolicyValidator(); + return op.execute(validator); + } + + private String createStepRecursive(String operationId, String parentStepId, OperationStepDto stepDto) { + OperationStep step = new OperationStep(); + if (stepDto.getIndex() != null) step.setIndex(stepDto.getIndex()); + if (stepDto.getName() != null) step.setName(stepDto.getName()); + if (stepDto.getMode() != null) step.setMode(stepDto.getMode()); + OperationStep savedStep = stepDao.save(step); + + // Link to operation or parent step + if (parentStepId == null) { + relDao.addStep(operationId, savedStep.getId()); + } else { + relDao.addSubStep(parentStepId, savedStep.getId()); + } + + // Optional executeOperation / useTechnology + if (stepDto.getExecuteOperationId() != null && !stepDto.getExecuteOperationId().isBlank()) { + relDao.setStepExecuteOperation(savedStep.getId(), stepDto.getExecuteOperationId()); + } + if (stepDto.getUseTechnologyId() != null && !stepDto.getUseTechnologyId().isBlank()) { + relDao.setStepUseTechnology(savedStep.getId(), stepDto.getUseTechnologyId()); + } + + // Input mappings + if (stepDto.getInputMappings() != null) { + for (AttributeMappingDto m : stepDto.getInputMappings()) { + String mappingId = createAttributeMapping(m).getId(); + relDao.addInputMappings(savedStep.getId(), java.util.List.of(mappingId)); + // Link to Attributes if provided + if (m.getInputAttributeId() != null && !m.getInputAttributeId().isBlank()) { + attributeMappingDao.linkInputAttribute(mappingId, m.getInputAttributeId()); + } + if (m.getOutputAttributeId() != null && !m.getOutputAttributeId().isBlank()) { + attributeMappingDao.linkOutputAttribute(mappingId, m.getOutputAttributeId()); + } + } + } + // Output mappings + if (stepDto.getOutputMappings() != null) { + for (AttributeMappingDto m : stepDto.getOutputMappings()) { + String mappingId = createAttributeMapping(m).getId(); + relDao.addOutputMappings(savedStep.getId(), java.util.List.of(mappingId)); + if (m.getInputAttributeId() != null && !m.getInputAttributeId().isBlank()) { + attributeMappingDao.linkInputAttribute(mappingId, m.getInputAttributeId()); + } + if (m.getOutputAttributeId() != null && !m.getOutputAttributeId().isBlank()) { + attributeMappingDao.linkOutputAttribute(mappingId, m.getOutputAttributeId()); + } + } + } + + // Sub-steps + if (stepDto.getSubSteps() != null && !stepDto.getSubSteps().isEmpty()) { + for (OperationStepDto child : stepDto.getSubSteps()) { + createStepRecursive(operationId, savedStep.getId(), child); + } + } + + return savedStep.getId(); + } + + private AttributeMapping createAttributeMapping(AttributeMappingDto dto) { + AttributeMapping m = new AttributeMapping(); + m.setName(dto.getName()); + if (dto.getReplaceCharactersInValueWithInput() != null) + m.setReplaceCharactersInValueWithInput(dto.getReplaceCharactersInValueWithInput()); + if (dto.getValue() != null) m.setValue(dto.getValue()); + if (dto.getIndex() != null) m.setIndex(dto.getIndex()); + return attributeMappingDao.save(m); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/OperationManagementService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/OperationManagementService.java new file mode 100644 index 0000000..261196e --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/OperationManagementService.java @@ -0,0 +1,156 @@ +/* + * 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.idoris.operations.services; + +import edu.kit.datamanager.idoris.core.domain.valueObjects.AttributeMapping; +import edu.kit.datamanager.idoris.core.domain.valueObjects.OperationStep; +import edu.kit.datamanager.idoris.operations.api.IOperationManagementExternalService; +import edu.kit.datamanager.idoris.operations.dao.IOperationDao; +import edu.kit.datamanager.idoris.operations.dao.IOperationRelationshipDao; +import edu.kit.datamanager.idoris.operations.dao.IOperationStepDao; +import edu.kit.datamanager.idoris.operations.dto.OperationStepDto; +import io.micrometer.observation.annotation.Observed; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +@Service +@Slf4j +@Observed(contextualName = "operationManagementService") +@RequiredArgsConstructor +class OperationManagementService implements IOperationManagementExternalService { + + private final IOperationDao operationDao; + private final IOperationStepDao stepDao; + private final IOperationRelationshipDao relDao; + + @Override + @Transactional(readOnly = true) + public List listSteps(String operationId) { + ensureOperationExists(operationId); + Iterable steps = relDao.listSteps(operationId); + return toDtos(steps); + } + + // Helpers + private void ensureOperationExists(String id) { + if (operationDao.findById(id).isEmpty()) { + throw new IllegalArgumentException("Operation not found: " + id); + } + } + + private List toDtos(Iterable steps) { + return java.util.stream.StreamSupport.stream(steps.spliterator(), false) + .map(this::toDto) + .toList(); + } + + private OperationStepDto toDto(OperationStep s) { + return OperationStepDto.builder() + .internalId(s.getId()) + .index(s.getIndex()) + .name(s.getName()) + .mode(s.getMode()) + .build(); + } + + @Override + @Transactional + public OperationStepDto createStep(String operationId, OperationStepDto stepDto) { + ensureOperationExists(operationId); + OperationStep step = new OperationStep(); + step.setIndex(stepDto.getIndex()); + step.setName(stepDto.getName()); + if (stepDto.getMode() != null) step.setMode(stepDto.getMode()); + OperationStep saved = stepDao.save(step); + relDao.addStep(operationId, saved.getId()); + return toDto(saved); + } + + @Override + @Transactional + public void removeSteps(String operationId, Set stepIds) { + ensureOperationExists(operationId); + if (stepIds == null || stepIds.isEmpty()) return; + relDao.removeSteps(operationId, stepIds); + } + + @Override + @Transactional + public void linkExistingSteps(String operationId, Collection stepIds) { + ensureOperationExists(operationId); + if (stepIds == null) return; + for (String sid : stepIds) { + // verify step exists + if (sid != null && stepDao.existsById(sid)) { + relDao.addStep(operationId, sid); + } + } + } + + @Override + @Transactional(readOnly = true) + public List listInputMappings(String stepId) { + Iterable mappings = relDao.listInputMappings(stepId); + return toIds(mappings); + } + + private List toIds(Iterable mappings) { + return java.util.stream.StreamSupport.stream(mappings.spliterator(), false) + .map(AttributeMapping::getId) + .toList(); + } + + @Override + @Transactional + public void addInputMappings(String stepId, Set mappingIds) { + if (mappingIds == null || mappingIds.isEmpty()) return; + relDao.addInputMappings(stepId, mappingIds); + } + + @Override + @Transactional + public void removeInputMappings(String stepId, Set mappingIds) { + if (mappingIds == null || mappingIds.isEmpty()) return; + relDao.removeInputMappings(stepId, mappingIds); + } + + @Override + @Transactional(readOnly = true) + public List listOutputMappings(String stepId) { + Iterable mappings = relDao.listOutputMappings(stepId); + return toIds(mappings); + } + + @Override + @Transactional + public void addOutputMappings(String stepId, Set mappingIds) { + if (mappingIds == null || mappingIds.isEmpty()) return; + relDao.addOutputMappings(stepId, mappingIds); + } + + @Override + @Transactional + public void removeOutputMappings(String stepId, Set mappingIds) { + if (mappingIds == null || mappingIds.isEmpty()) return; + relDao.removeOutputMappings(stepId, mappingIds); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/OperationService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/OperationService.java new file mode 100644 index 0000000..51d3483 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/services/OperationService.java @@ -0,0 +1,223 @@ +/* + * 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.idoris.operations.services; + +import edu.kit.datamanager.idoris.core.domain.Operation; +import edu.kit.datamanager.idoris.core.events.EventPublisherService; +import edu.kit.datamanager.idoris.operations.dao.IOperationDao; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Service for managing Operation entities. + * This logic provides methods for creating, updating, and retrieving Operation entities. + * It publishes domain events when entities are created, updated, or deleted. + */ +@Service +@Slf4j +@Observed(contextualName = "operationService") +public class OperationService { + private final IOperationDao operationDao; + private final EventPublisherService eventPublisher; + + /** + * Creates a new OperationService with the given dependencies. + * + * @param operationDao the Operation repository + * @param eventPublisher the event publisher logic + */ + public OperationService(IOperationDao operationDao, EventPublisherService eventPublisher) { + this.operationDao = operationDao; + this.eventPublisher = eventPublisher; + } + + /** + * Creates a new Operation entity. + * + * @param operation the Operation entity to create + * @return the created Operation entity + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "operationService.createOperation", description = "Time taken to create an operation", histogram = true) + @Counted(value = "operationService.createOperation.count", description = "Number of operation creations") + public Operation createOperation(@SpanAttribute Operation operation) { + log.debug("Creating Operation: {}", operation); + Operation saved = operationDao.save(operation); + eventPublisher.publishEntityCreated(saved); + log.info("Created Operation with PID: {}", saved.getId()); + return saved; + } + + /** + * Updates an existing Operation entity. + * + * @param operation the Operation entity to update + * @return the updated Operation entity + * @throws IllegalArgumentException if the Operation does not exist + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "operationService.updateOperation", description = "Time taken to update an operation", histogram = true) + @Counted(value = "operationService.updateOperation.count", description = "Number of operation updates") + public Operation updateOperation(@SpanAttribute Operation operation) { + log.debug("Updating Operation: {}", operation); + + if (operation.getId() == null || operation.getId().isEmpty()) { + throw new IllegalArgumentException("Operation must have a PID to be updated"); + } + + // Get the current version before updating + Operation existing = operationDao.findById(operation.getId()) + .orElseThrow(() -> new IllegalArgumentException("Operation not found with PID: " + operation.getId())); + + Long previousVersion = existing.getVersion(); + + Operation saved = operationDao.save(operation); + eventPublisher.publishEntityUpdated(saved, previousVersion); + log.info("Updated Operation with PID: {}", saved.getId()); + return saved; + } + + /** + * Deletes an Operation entity. + * + * @param id the PID or internal ID of the Operation to delete + * @throws IllegalArgumentException if the Operation does not exist + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "operationService.deleteOperation", description = "Time taken to delete an operation", histogram = true) + @Counted(value = "operationService.deleteOperation.count", description = "Number of operation deletions") + public void deleteOperation(@SpanAttribute String id) { + log.debug("Deleting Operation with ID: {}", id); + + Operation operation = operationDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Operation not found with ID: " + id)); + + operationDao.delete(operation); + eventPublisher.publishEntityDeleted(operation); + log.info("Deleted Operation with ID: {}", id); + } + + /** + * Retrieves an Operation entity by its PID or internal ID. + * + * @param id the PID or internal ID of the Operation to retrieve + * @return an Optional containing the Operation, or empty if not found + */ + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "operationService.getOperation", description = "Time taken to get an operation", histogram = true) + @Counted(value = "operationService.getOperation.count", description = "Number of operation retrievals") + public Optional getOperation(@SpanAttribute String id) { + log.debug("Retrieving Operation with ID: {}", id); + return operationDao.findById(id); + } + + /** + * Retrieves all Operation entities. + * + * @return a list of all Operation entities + */ + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "operationService.getAllOperations", description = "Time taken to get all operations", histogram = true) + @Counted(value = "operationService.getAllOperations.count", description = "Number of get all operations requests") + public List getAllOperations() { + log.debug("Retrieving all Operations"); + return operationDao.findAll(); + } + + /** + * Retrieves all Operations for a DataType. + * + * @param dataTypeId the ID of the DataType (either PID or internal ID) + * @return an iterable of Operations for the DataType + */ + @Transactional(readOnly = true) + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "operationService.getOperationsForDataType", description = "Time taken to get operations for data type", histogram = true) + @Counted(value = "operationService.getOperationsForDataType.count", description = "Number of get operations for data type requests") + public Iterable getOperationsForDataType(@SpanAttribute String dataTypeId) { + log.debug("Retrieving Operations for DataType with ID: {}", dataTypeId); + return operationDao.getOperationsForDataType(dataTypeId); + } + + /** + * Partially updates an existing Operation entity. + * + * @param id the PID or internal ID of the Operation to patch + * @param operationPatch the partial Operation entity with fields to update + * @return the patched Operation entity + * @throws IllegalArgumentException if the Operation does not exist + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "operationService.patchOperation", description = "Time taken to patch an operation", histogram = true) + @Counted(value = "operationService.patchOperation.count", description = "Number of operation patches") + public Operation patchOperation(@SpanAttribute String id, @SpanAttribute Operation operationPatch) { + log.debug("Patching Operation with ID: {}, patch: {}", id, operationPatch); + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("Operation ID cannot be null or empty"); + } + + // Get the current entity + Operation existing = operationDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Operation not found with ID: " + id)); + Long previousVersion = existing.getVersion(); + + // Apply non-null fields from the patch to the existing entity + if (operationPatch.getName() != null) { + existing.setName(operationPatch.getName()); + } + if (operationPatch.getDescription() != null) { + existing.setDescription(operationPatch.getDescription()); + } + if (operationPatch.getExecutableOn() != null) { + existing.setExecutableOn(operationPatch.getExecutableOn()); + } + if (operationPatch.getReturns() != null) { + existing.setReturns(operationPatch.getReturns()); + } + if (operationPatch.getEnvironment() != null) { + existing.setEnvironment(operationPatch.getEnvironment()); + } + if (operationPatch.getExecution() != null) { + existing.setExecution(operationPatch.getExecution()); + } + + // Save the updated entity + Operation saved = operationDao.save(existing); + + // Publish the patched event + eventPublisher.publishEntityPatched(saved, previousVersion); + + log.info("Patched Operation with PID: {}", saved.getId()); + return saved; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/api/IOperationApi.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/api/IOperationApi.java new file mode 100644 index 0000000..a63acbc --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/api/IOperationApi.java @@ -0,0 +1,156 @@ +/* + * 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.idoris.operations.web.api; + +import edu.kit.datamanager.idoris.operations.dto.OperationRequestDto; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * API interface for Operation endpoints (DTO-first). + */ +@Tag(name = "Operation", description = "API for managing Operations") +public interface IOperationApi { + + @GetMapping + @io.swagger.v3.oas.annotations.Operation( + summary = "Get all Operations", + description = "Returns a collection of all Operation DTOs", + responses = { + @ApiResponse(responseCode = "200", description = "Operations found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = OperationResponseDto.class))) + } + ) + ResponseEntity>> getAllOperations(); + + @GetMapping("/{id}") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get an Operation by PID or internal ID", + description = "Returns an Operation DTO by its PID or internal ID", + responses = { + @ApiResponse(responseCode = "200", description = "Operation found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = OperationResponseDto.class))), + @ApiResponse(responseCode = "404", description = "Operation not found") + } + ) + ResponseEntity> getOperation( + @Parameter(description = "PID or internal ID of the Operation", required = true) + @PathVariable String id); + + @PostMapping + @io.swagger.v3.oas.annotations.Operation( + summary = "Create a new Operation", + description = "Creates a new Operation DTO after validating it", + responses = { + @ApiResponse(responseCode = "201", description = "Operation created", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = OperationResponseDto.class))), + @ApiResponse(responseCode = "400", description = "Invalid input or validation failed") + } + ) + ResponseEntity> createOperation( + @Parameter(description = "Operation to create", required = true) + @Valid @RequestBody OperationRequestDto operation); + + @PutMapping("/{id}") + @io.swagger.v3.oas.annotations.Operation( + summary = "Update an Operation", + description = "Updates an existing Operation DTO after validating it", + responses = { + @ApiResponse(responseCode = "200", description = "Operation updated", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = OperationResponseDto.class))), + @ApiResponse(responseCode = "400", description = "Invalid input or validation failed"), + @ApiResponse(responseCode = "404", description = "Operation not found") + } + ) + ResponseEntity> updateOperation( + @Parameter(description = "PID or internal ID of the Operation", required = true) + @PathVariable String id, + @Parameter(description = "Updated Operation", required = true) + @Valid @RequestBody OperationRequestDto operation); + + @DeleteMapping("/{id}") + @io.swagger.v3.oas.annotations.Operation( + summary = "Delete an Operation", + description = "Deletes an Operation DTO", + responses = { + @ApiResponse(responseCode = "204", description = "Operation deleted"), + @ApiResponse(responseCode = "404", description = "Operation not found") + } + ) + ResponseEntity deleteOperation( + @Parameter(description = "PID or internal ID of the Operation", required = true) + @PathVariable String id); + + @GetMapping("/{id}/validate") + @io.swagger.v3.oas.annotations.Operation( + summary = "Validate an Operation", + description = "Validates an Operation and returns the validation result", + responses = { + @ApiResponse(responseCode = "200", description = "Operation is valid"), + @ApiResponse(responseCode = "218", description = "Operation is invalid"), + @ApiResponse(responseCode = "404", description = "Operation not found") + } + ) + ResponseEntity validate( + @Parameter(description = "PID or internal ID of the Operation", required = true) + @PathVariable String id); + + @GetMapping("/search/getOperationsForDataType") + @io.swagger.v3.oas.annotations.Operation( + summary = "Get operations for a data type", + description = "Returns a collection of operations that can be executed on a data type", + responses = { + @ApiResponse(responseCode = "200", description = "Operations found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = OperationResponseDto.class))) + } + ) + ResponseEntity>> getOperationsForDataType( + @Parameter(description = "PID or internal ID of the data type", required = true) + @RequestParam String id); + + @PatchMapping("/{id}") + @io.swagger.v3.oas.annotations.Operation( + summary = "Partially update an Operation", + description = "Updates specific fields of an existing Operation DTO", + responses = { + @ApiResponse(responseCode = "200", description = "Operation patched", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = OperationResponseDto.class))), + @ApiResponse(responseCode = "400", description = "Invalid input"), + @ApiResponse(responseCode = "404", description = "Operation not found") + } + ) + ResponseEntity> patchOperation( + @Parameter(description = "PID or internal ID of the Operation", required = true) + @PathVariable String id, + @Parameter(description = "Partial Operation with fields to update", required = true) + @RequestBody OperationRequestDto operationPatch); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/api/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/api/package-info.java new file mode 100644 index 0000000..7566448 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/api/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +@org.springframework.modulith.NamedInterface("operations.web.api") +package edu.kit.datamanager.idoris.operations.web.api; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/hateoas/OperationDtoModelAssembler.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/hateoas/OperationDtoModelAssembler.java new file mode 100644 index 0000000..e4c8edd --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/hateoas/OperationDtoModelAssembler.java @@ -0,0 +1,71 @@ +/* + * 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.idoris.operations.web.hateoas; + +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import edu.kit.datamanager.idoris.operations.web.v1.OperationController; +import edu.kit.datamanager.idoris.pids.api.IInternalPIDService; +import io.micrometer.observation.annotation.Observed; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +/** + * Assembler that adds HATEOAS links to OperationDto responses. + */ +@Component +@Observed(contextualName = "operationDtoModelAssembler") +public class OperationDtoModelAssembler implements RepresentationModelAssembler> { + + @Autowired(required = false) + IInternalPIDService pidService; + + @Override + public EntityModel toModel(OperationResponseDto dto) { + EntityModel model = EntityModel.of(dto); + + // Collection link + model.add(linkTo(methodOn(OperationController.class).getAllOperations()).withRel("collection")); + + // Add PID link if resolvable + if (dto.getInternalId() != null && pidService != null) { + pidService.getPIDLinkForInternalID(dto.getInternalId()).forEach(model::add); + } + + String base = "/v1/operations/{id}"; + if (dto.getInternalId() != null) { + model.add(linkTo(methodOn(OperationController.class).getOperation(dto.getInternalId())).withSelfRel()); + model.add(linkTo(methodOn(OperationController.class).validate(dto.getInternalId())).withRel("validate")); + } else { + model.add(Link.of(base).withSelfRel().withTitle("self (templated)")); + } + + return model; + } + + @Override + public CollectionModel> toCollectionModel(Iterable entities) { + CollectionModel> collection = RepresentationModelAssembler.super.toCollectionModel(entities); + collection.add(linkTo(methodOn(OperationController.class).getAllOperations()).withSelfRel()); + return collection; + } +} diff --git a/src/main/java/edu/kit/datamanager/idoris/domain/entities/TextUser.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/hateoas/OperationModelAssembler.java similarity index 57% rename from src/main/java/edu/kit/datamanager/idoris/domain/entities/TextUser.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/hateoas/OperationModelAssembler.java index 016c301..360e880 100644 --- a/src/main/java/edu/kit/datamanager/idoris/domain/entities/TextUser.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/hateoas/OperationModelAssembler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Karlsruhe Institute of Technology + * 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. @@ -14,19 +14,14 @@ * limitations under the License. */ -package edu.kit.datamanager.idoris.domain.entities; +package edu.kit.datamanager.idoris.operations.web.hateoas; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Setter; -import org.springframework.data.neo4j.core.schema.Node; - -@Getter -@Setter -@AllArgsConstructor -@Node("TextUser") -public final class TextUser extends User { - private String name; - private String email; - private String details; -} +/** + * Placeholder kept for binary/source compatibility. + * The Operation API is DTO-first and uses OperationDtoModelAssembler. + * This class is intentionally not a Spring component and does not + * implement any assembler interface. + */ +final class OperationModelAssembler { + // intentionally empty +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/v1/OperationController.java b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/v1/OperationController.java new file mode 100644 index 0000000..91928ee --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/operations/web/v1/OperationController.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2024-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.idoris.operations.web.v1; + +import edu.kit.datamanager.idoris.operations.api.IOperationExternalService; +import edu.kit.datamanager.idoris.operations.api.IOperationManagementExternalService; +import edu.kit.datamanager.idoris.operations.dto.OperationRequestDto; +import edu.kit.datamanager.idoris.operations.dto.OperationResponseDto; +import edu.kit.datamanager.idoris.operations.web.hateoas.OperationDtoModelAssembler; +import edu.kit.datamanager.idoris.rules.validation.ValidationResult; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +/** + * DTO-first REST controller for Operations. + */ +@RestController +@RequestMapping("/v1/operations") +@Tag(name = "Operation", description = "API for managing Operations") +@Observed(contextualName = "operationController") +public class OperationController { + + private final IOperationExternalService operationService; + + private final IOperationManagementExternalService operationManagementService; + + private final OperationDtoModelAssembler assembler; + + public OperationController(IOperationExternalService operationService, IOperationManagementExternalService operationManagementService, OperationDtoModelAssembler assembler) { + this.operationService = operationService; + this.operationManagementService = operationManagementService; + this.assembler = assembler; + } + + @GetMapping + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "operationController.list", histogram = true) + @Counted(value = "operationController.list.count") + public ResponseEntity>> getAllOperations() { + List dtos = operationService.list(); + List> models = dtos.stream().map(assembler::toModel).collect(Collectors.toList()); + return ResponseEntity.ok(CollectionModel.of(models, linkTo(methodOn(OperationController.class).getAllOperations()).withSelfRel())); + } + + @GetMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "operationController.get", histogram = true) + @Counted(value = "operationController.get.count") + public ResponseEntity> getOperation( + @Parameter(description = "PID or internal ID of the Operation", required = true) + @SpanAttribute("operation.id") @PathVariable String id) { + return operationService.get(id) + .map(assembler::toModel) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "operationController.create", histogram = true) + @Counted(value = "operationController.create.count") + public ResponseEntity> createOperation(@Valid @RequestBody OperationRequestDto dto) { + OperationResponseDto created = operationService.create(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(assembler.toModel(created)); + } + + @PutMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "operationController.update", histogram = true) + @Counted(value = "operationController.update.count") + public ResponseEntity> updateOperation( + @SpanAttribute @PathVariable String id, + @Valid @RequestBody OperationRequestDto dto) { + if (operationService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + OperationResponseDto updated = operationService.update(id, dto); + return ResponseEntity.ok(assembler.toModel(updated)); + } + + @DeleteMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "operationController.delete", histogram = true) + @Counted(value = "operationController.delete.count") + public ResponseEntity deleteOperation(@SpanAttribute("operation.id") @PathVariable String id) { + if (operationService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + operationService.delete(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/validate") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "operationController.validate", histogram = true) + @Counted(value = "operationController.validate.count") + public ResponseEntity validate(@SpanAttribute("operation.id") @PathVariable String id) { + if (operationService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + ValidationResult result = operationService.validate(id); + if (result.isValid()) return ResponseEntity.ok(result); + return ResponseEntity.status(218).body(result); + } + + @GetMapping("/search/getOperationsForDataType") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "operationController.getOperationsForDataType", histogram = true) + @Counted(value = "operationController.getOperationsForDataType.count") + public ResponseEntity>> getOperationsForDataType( + @Parameter(description = "PID or internal ID of the data type", required = true) + @SpanAttribute("dataType.id") @RequestParam String id) { + List dtos = operationService.getOperationsForDataType(id); + List> models = dtos.stream().map(assembler::toModel).collect(Collectors.toList()); + return ResponseEntity.ok(CollectionModel.of(models, linkTo(methodOn(OperationController.class).getOperationsForDataType(id)).withSelfRel())); + } + + @PatchMapping("/{id}") + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "operationController.patch", histogram = true) + @Counted(value = "operationController.patch.count") + public ResponseEntity> patchOperation( + @SpanAttribute @PathVariable String id, + @RequestBody OperationRequestDto dto) { + if (operationService.get(id).isEmpty()) { + return ResponseEntity.notFound().build(); + } + OperationResponseDto patched = operationService.patch(id, dto); + return ResponseEntity.ok(assembler.toModel(patched)); + } + + // ===== Steps management ===== + @GetMapping("/{id}/steps") + public ResponseEntity> listSteps(@PathVariable String id) { + if (operationService.get(id).isEmpty()) return ResponseEntity.notFound().build(); + return ResponseEntity.ok(operationManagementService.listSteps(id)); + } + + @PostMapping("/{id}/steps") + public ResponseEntity createStep(@PathVariable String id, + @RequestBody edu.kit.datamanager.idoris.operations.dto.OperationStepDto step) { + if (operationService.get(id).isEmpty()) return ResponseEntity.notFound().build(); + edu.kit.datamanager.idoris.operations.dto.OperationStepDto created = operationManagementService.createStep(id, step); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @DeleteMapping("/{id}/steps") + public ResponseEntity deleteSteps(@PathVariable String id, @RequestBody java.util.Set stepIds) { + if (operationService.get(id).isEmpty()) return ResponseEntity.notFound().build(); + operationManagementService.removeSteps(id, stepIds == null ? java.util.Set.of() : stepIds); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{id}/steps") + public ResponseEntity setSteps(@PathVariable String id, @RequestBody java.util.List stepIds) { + if (operationService.get(id).isEmpty()) return ResponseEntity.notFound().build(); + java.util.List current = operationManagementService.listSteps(id); + java.util.Set currentIds = current.stream().map(edu.kit.datamanager.idoris.operations.dto.OperationStepDto::getInternalId).filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toSet()); + java.util.Set desired = stepIds == null ? java.util.Set.of() : new java.util.HashSet<>(stepIds); + // remove missing + java.util.Set toRemove = new java.util.HashSet<>(currentIds); + toRemove.removeAll(desired); + if (!toRemove.isEmpty()) operationManagementService.removeSteps(id, toRemove); + // add missing (link existing) + java.util.Set toAdd = new java.util.HashSet<>(desired); + toAdd.removeAll(currentIds); + if (!toAdd.isEmpty()) operationManagementService.linkExistingSteps(id, toAdd); + return ResponseEntity.noContent().build(); + } + + // ===== Attribute mappings management for steps ===== + @GetMapping("/steps/{stepId}/inputMappings") + public ResponseEntity> listInputMappings(@PathVariable String stepId) { + return ResponseEntity.ok(operationManagementService.listInputMappings(stepId)); + } + + @PostMapping("/steps/{stepId}/inputMappings") + public ResponseEntity addInputMappings(@PathVariable String stepId, @RequestBody java.util.Set mappingIds) { + operationManagementService.addInputMappings(stepId, mappingIds); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/steps/{stepId}/inputMappings") + public ResponseEntity removeInputMappings(@PathVariable String stepId, @RequestBody java.util.Set mappingIds) { + operationManagementService.removeInputMappings(stepId, mappingIds); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/steps/{stepId}/outputMappings") + public ResponseEntity> listOutputMappings(@PathVariable String stepId) { + return ResponseEntity.ok(operationManagementService.listOutputMappings(stepId)); + } + + @PostMapping("/steps/{stepId}/outputMappings") + public ResponseEntity addOutputMappings(@PathVariable String stepId, @RequestBody java.util.Set mappingIds) { + operationManagementService.addOutputMappings(stepId, mappingIds); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/steps/{stepId}/outputMappings") + public ResponseEntity removeOutputMappings(@PathVariable String stepId, @RequestBody java.util.Set mappingIds) { + operationManagementService.removeOutputMappings(stepId, mappingIds); + return ResponseEntity.noContent().build(); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/MetadataEventListener.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/MetadataEventListener.java new file mode 100644 index 0000000..c6a1568 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/MetadataEventListener.java @@ -0,0 +1,143 @@ +/* + * 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.idoris.pids; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import edu.kit.datamanager.idoris.core.events.EntityCreatedEvent; +import edu.kit.datamanager.idoris.core.events.EntityDeletedEvent; +import edu.kit.datamanager.idoris.core.events.EntityUpdatedEvent; +import edu.kit.datamanager.idoris.core.events.EventPublisherService; +import edu.kit.datamanager.idoris.pids.domain.PIDNode; +import edu.kit.datamanager.idoris.pids.services.PersistentIdentifierService; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * Event listener that generates IDs for newly created entities. + * This listener subscribes to EntityCreatedEvent and uses the PersistentIdentifierService + * to create PIDNode entities for newly created AdministrativeMetadata entities. + */ +@Component +@Slf4j +@Observed(contextualName = "metadataEventListener") +public class MetadataEventListener { + private final PersistentIdentifierService pidService; + private final EventPublisherService eventPublisher; + + /** + * Creates a new MetadataEventListener with the given dependencies. + * + * @param pidService the PersistentIdentifierService + * @param eventPublisher the event publisher logic + */ + public MetadataEventListener(PersistentIdentifierService pidService, EventPublisherService eventPublisher) { + this.pidService = pidService; + this.eventPublisher = eventPublisher; + } + + /** + * Handles EntityCreatedEvent by creating a PIDNode for the entity. + * This method is executed in a new transaction automatically by Spring Modulith to ensure that the ID creation is isolated from the transaction that created the entity. + * + * @param event the entity created event + */ + @ApplicationModuleListener + @WithSpan(kind = SpanKind.CONSUMER) + @Timed(value = "metadataEventListener.handleEntityCreatedEvent", description = "Time taken to handle entity created event", histogram = true) + @Counted(value = "metadataEventListener.handleEntityCreatedEvent.count", description = "Number of entity created events handled") + public void handleEntityCreatedEvent(EntityCreatedEvent event) { + AdministrativeMetadata entity = event.getEntity(); + log.debug("Handling EntityCreatedEvent for entity: {}", entity); + + // Check if a PIDNode already exists for this entity + Optional existingPid = pidService.getPersistentIdentifier(entity); + + if (existingPid.isPresent()) { + log.debug("Entity already has a PIDNode: {}", existingPid.get().getPid()); + return; + } + + // Create a new PIDNode for the entity + log.info("Creating PIDNode for entity: {}", entity); + PIDNode pid = pidService.createPersistentIdentifier(entity); + log.info("Created PIDNode with ID: {} for entity: {}", pid.getPid(), entity); + + // Publish an ID generated event + eventPublisher.publishIDGenerated(entity, pid.getPid().toString()); + } + + /** + * Handles EntityUpdatedEvent by updating the PIDNode for the entity. + * This method is executed in a new transaction automatically by Spring Modulith to ensure that the ID update is isolated from the transaction that updated the entity. + * + * @param event the entity updated event + */ + @ApplicationModuleListener + @WithSpan(kind = SpanKind.CONSUMER) + @Timed(value = "metadataEventListener.handleEntityUpdatedEvent", description = "Time taken to handle entity updated event", histogram = true) + @Counted(value = "metadataEventListener.handleEntityUpdatedEvent.count", description = "Number of entity updated events handled") + public void handleEntityUpdatedEvent(EntityUpdatedEvent event) { + AdministrativeMetadata entity = event.getEntity(); + log.debug("Handling EntityUpdatedEvent for entity: {}", entity); + + // Check if a PIDNode exists for this entity + Optional existingPid = pidService.getPersistentIdentifier(entity); + + if (existingPid.isPresent()) { + PIDNode pid = existingPid.get(); + log.info("Updating PIDNode for entity: {}", entity); + pidService.updatePIDRecord(pid); + log.info("Updated PIDNode with ID: {} for entity: {}", pid.getPid(), entity); + } else { + log.warn("No PIDNode found for entity, cannot update: {}", entity); + } + } + + + /** + * Handles EntityDeletedEvent by marking the PIDNode as a tombstone. + * This method is executed in a new transaction automatically by Spring Modulith to ensure that the tombstone creation is isolated from the transaction that deleted the entity. + * + * @param event the entity deleted event + */ + @ApplicationModuleListener + @WithSpan(kind = SpanKind.CONSUMER) + @Timed(value = "metadataEventListener.handleEntityDeletedEvent", description = "Time taken to handle entity deleted event", histogram = true) + @Counted(value = "metadataEventListener.handleEntityDeletedEvent.count", description = "Number of entity deleted events handled") + public void handleEntityDeletedEvent(EntityDeletedEvent event) { + AdministrativeMetadata entity = event.getEntity(); + log.debug("Handling EntityDeletedEvent for entity: {}", entity); + + // Mark the PIDNode as a tombstone + Optional optionalPid = pidService.markAsTombstone(entity); + + if (optionalPid.isPresent()) { + PIDNode pid = optionalPid.get(); + log.info("Created tombstone for entity with ID: {}", pid.getPid()); + } else { + log.warn("No PIDNode found for entity, cannot create tombstone: {}", entity); + } + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/api/IInternalPIDService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/api/IInternalPIDService.java new file mode 100644 index 0000000..ffeb185 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/api/IInternalPIDService.java @@ -0,0 +1,55 @@ +/* + * 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.idoris.pids.api; + +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; +import org.springframework.hateoas.Link; +import org.springframework.modulith.NamedInterface; + +import java.util.List; + +/** + * This API enables other endpoints to retrieve PIDs associated with entities in IDORIS and uniformly resolve them. + * + * @author maximiliani + */ +@NamedInterface +public interface IInternalPIDService { + /** + * This method makes a lookup for an internal ID and returns all PIDs pointing to this ID. + * + * @param internalId The internal ID of the entity that might have PIDs + * @return All PIDs associated with this internal ID. If none are found, this list is empty. + */ + List getPIDAssociatedWithInternalID(String internalId); + + /** + * This method retrieves all PIDs for the internal ID and returns a list of HATEOAS links that resolve this PID. + * + * @param internalId The internal ID of the entity that might have PIDs + * @return A list of HATEOAS links to the /pid endpoint of IDORIS, which will resolve and redirect to the domain entity. + */ + List getPIDLinkForInternalID(String internalId); + + /** + * This method converts a PID into a link to the /pid/{pid} endpoint of IDORIS + * + * @param pid A valid PID + * @return A HATEOAS link to the /pid/{pid} endpoint, which resolves the PID and redirects the user to the domain entity. + */ + Link getLinkForPID(PID pid); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/TypedPIDMakerClient.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/TypedPIDMakerClient.java new file mode 100644 index 0000000..c9958ad --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/TypedPIDMakerClient.java @@ -0,0 +1,90 @@ +/* + * 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.idoris.pids.client; + +import edu.kit.datamanager.idoris.pids.client.model.PIDRecord; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.service.annotation.PutExchange; + +/** + * Client for the Typed PID Maker logic. + * This interface defines the operations for interacting with the logic. + */ +@HttpExchange("/api/v1/pit/pid") +@Observed(contextualName = "pidMakerClient") +public interface TypedPIDMakerClient { + + /** + * Creates a new PID record using the SimplePidRecord format. + * + * @param record The PID record to create + * @return The created PID record with response headers (including ETag) + */ + @PostExchange( + value = "/", + accept = "application/vnd.datamanager.pid.simple+json", + contentType = "application/vnd.datamanager.pid.simple+json") + @WithSpan(kind = SpanKind.CLIENT) + @Timed(value = "pidMakerClient.createPIDRecord", description = "Time taken to create a PID record", histogram = true) + @Counted(value = "pidMakerClient.createPIDRecord.count", description = "Number of PID record creations") + ResponseEntity createPIDRecord(@RequestBody PIDRecord record); + + /** + * Gets a PID record by its PID using the SimplePidRecord format. + * + * @param pid The PID of the record to get + * @return The PID record with response headers (including ETag) + */ + @GetExchange( + value = "/{pid}", + accept = "application/vnd.datamanager.pid.simple+json") + @WithSpan(kind = SpanKind.CLIENT) + @Timed(value = "pidMakerClient.getPIDRecord", description = "Time taken to retrieve a PID record", histogram = true) + @Counted(value = "pidMakerClient.getPIDRecord.count", description = "Number of PID record retrievals") + ResponseEntity getPIDRecord(@SpanAttribute("pid.value") @PathVariable String pid); + + /** + * Updates an existing PID record using the SimplePidRecord format. + * + * @param pid The PID of the record to update + * @param record The updated PID record + * @param etag The ETag value for the If-Match header + * @return The updated PID record with response headers (including ETag) + */ + @PutExchange( + value = "/{pid}", + accept = "application/vnd.datamanager.pid.simple+json", + contentType = "application/vnd.datamanager.pid.simple+json") + @WithSpan(kind = SpanKind.CLIENT) + @Timed(value = "pidMakerClient.updatePIDRecord", description = "Time taken to update a PID record", histogram = true) + @Counted(value = "pidMakerClient.updatePIDRecord.count", description = "Number of PID record updates") + ResponseEntity updatePIDRecord(@SpanAttribute("pid.value") @PathVariable String pid, + @RequestBody PIDRecord record, + @RequestHeader("If-Match") String etag); +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/TypedPIDMakerClientConfig.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/TypedPIDMakerClientConfig.java new file mode 100644 index 0000000..2d84222 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/TypedPIDMakerClientConfig.java @@ -0,0 +1,207 @@ +/* + * 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.idoris.pids.client; + +import edu.kit.datamanager.idoris.core.configuration.TypedPIDMakerConfig; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NullMarked; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; + +/** + * Configuration class for the TypedPIDMakerClient. + * This class registers the TypedPIDMakerClient as a bean in the Spring application context. + */ +@Configuration +@ConditionalOnBean(TypedPIDMakerConfig.class) +@Observed +@Slf4j +@NullMarked +public class TypedPIDMakerClientConfig { + + /** + * Creates a TypedPIDMakerClient bean using Spring HTTP Interface. + * + * @param config The TypedPIDMakerConfig + * @return The TypedPIDMakerClient + */ + @Bean + @WithSpan(kind = SpanKind.CLIENT) + @Timed(value = "typedPIDMakerClientConfig.createClient", description = "Time taken to create TypedPIDMakerClient", histogram = true) + @Counted(value = "typedPIDMakerClientConfig.createClient.count", description = "Number of TypedPIDMakerClient creations") + public TypedPIDMakerClient typedPIDMakerClient(TypedPIDMakerConfig config, JsonMapper jsonMapper) { + log.info("Creating TypedPIDMakerClient with base URL: {}", config.getBaseUrl()); + // Create a client HTTP request factory with the configured timeout + ClientHttpRequestFactory requestFactory = new DecodingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()); + + RestClient restClient = RestClient.builder() + .baseUrl(config.getBaseUrl()) + .requestInterceptor((request, body, execution) -> { + // Manual trace header injection + Span currentSpan = Span.current(); + if (currentSpan != null && currentSpan.getSpanContext().isValid()) { + SpanContext spanContext = currentSpan.getSpanContext(); + String traceParent = String.format("00-%s-%s-%s", spanContext.getTraceId(), spanContext.getSpanId(), spanContext.getTraceFlags().asHex()); + request.getHeaders().add("traceparent", traceParent); + + // Add tracestate if available + if (!spanContext.getTraceState().isEmpty()) { + request.getHeaders().add("tracestate", spanContext.getTraceState().toString()); + } + + currentSpan.setAttribute("request.method", request.getMethod().toString()); + currentSpan.setAttribute("request.url", request.getURI().toString()); + currentSpan.setAttribute("request.headers", request.getHeaders().toString()); + currentSpan.setAttribute("request.body", new String(body)); + } + + log.debug("Outgoing request headers: {}", request.getHeaders()); + + // Execute the request and capture the response + ClientHttpResponse response = execution.execute(request, body); + + log.debug("Incoming response status {}", response.getStatusCode()); + log.debug("Incoming response headers: {}", response.getHeaders()); + log.debug("Incoming content type {}", response.getHeaders().getContentType()); + + // Add response attributes to the span + if (currentSpan != null && currentSpan.getSpanContext().isValid()) { + try { + currentSpan.setAttribute("response.status_code", response.getStatusCode().value()); + currentSpan.setAttribute("response.status_text", response.getStatusText()); + currentSpan.setAttribute("response.headers", response.getHeaders().toString()); + + // Read the response body for span attributes + // Note: This creates a buffered response to avoid consuming the stream + byte[] bodyBytes = response.getBody().readAllBytes(); + String responseBody = new String(bodyBytes, StandardCharsets.UTF_8); + currentSpan.setAttribute("response.body", responseBody); + + log.debug("Incoming response body: {}", responseBody); + + // Return a new response with the buffered body + return new BufferedClientHttpResponse(response, bodyBytes); + } catch (IOException e) { + log.warn("Failed to read response body for span attributes", e); + currentSpan.setAttribute("response.status_code", response.getStatusCode().value()); + currentSpan.setAttribute("response.status_text", response.getStatusText()); + currentSpan.setAttribute("response.headers", response.getHeaders().toString()); + currentSpan.setAttribute("response.body.error", "Failed to read response body: " + e.getMessage()); + log.error("Failed to read response body", e); + } + } + + return response; + + }) + .requestFactory(requestFactory) + .configureMessageConverters(client -> client.registerDefaults().withJsonConverter(new JacksonJsonHttpMessageConverter(jsonMapper))) + .build(); + + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build(); + + TypedPIDMakerClient client = factory.createClient(TypedPIDMakerClient.class); + log.info("Successfully created TypedPIDMakerClient for base URL: {}", config.getBaseUrl()); + return client; + } + + /** + * This record wraps a ClientHttpRequestFactory to decode slashes in URIs. + * This is necessary because some PID services may encode slashes in URIs as %2F. + * + * @param delegate the original ClientHttpRequestFactory to delegate to. + */ + private record DecodingClientHttpRequestFactory( + ClientHttpRequestFactory delegate) implements ClientHttpRequestFactory { + + @Override + public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException { + URI decodedUri = decodeSlashes(uri); + return delegate.createRequest(decodedUri, httpMethod); + } + + private URI decodeSlashes(URI uri) { + String uriString = uri.toString(); + if (uriString.contains("%2F")) { + String decodedUriString = uriString.replace("%2F", "/"); + try { + return new URI(decodedUriString); + } catch (URISyntaxException e) { + // If decoding fails, return the original URI + return uri; + } + } + return uri; + } + } + + /** + * A wrapper for ClientHttpResponse that allows the body to be read multiple times. + */ + private record BufferedClientHttpResponse(ClientHttpResponse delegate, + byte[] bufferedBody) implements ClientHttpResponse { + + @Override + public HttpStatusCode getStatusCode() throws IOException { + return delegate.getStatusCode(); + } + + @Override + public String getStatusText() throws IOException { + return delegate.getStatusText(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public java.io.InputStream getBody() throws IOException { + return new java.io.ByteArrayInputStream(bufferedBody); + } + + @Override + public org.springframework.http.HttpHeaders getHeaders() { + return delegate.getHeaders(); + } + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecord.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecord.java new file mode 100644 index 0000000..1b266fc --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecord.java @@ -0,0 +1,67 @@ +/* + * 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.idoris.pids.client.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; +import lombok.With; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Represents a Simple PID Record in the Typed PID Maker logic. + * This follows the SimplePidRecord structure from the Typed PID Maker API. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@With +public record PIDRecord(PID pid, List record) { + + /** + * Constructs a PIDRecord with the given PID and an empty record. + * If the PID is null, it defaults to an empty string. + * + * @param pid The PID of the record + */ + public PIDRecord(PID pid) { + this(pid, new ArrayList<>()); + } + + /** + * Constructs a PIDRecord with the given PID and record entries. + * If the PID is null, it defaults to an empty string. + * If the record is null, it initializes an empty list. + * + * @param pid The PID of the record + * @param record The list of PIDRecordEntry entries + */ + public PIDRecord(PID pid, List record) { + this.pid = pid; + this.record = Objects.requireNonNullElseGet(record, ArrayList::new); + } + + /** + * Constructs a PIDRecord with an empty PID and the given record entries. + * If the record is null, it initializes an empty list. + * + * @param record The list of PIDRecordEntry entries + */ + public PIDRecord(List record) { + this(null, Objects.requireNonNullElseGet(record, ArrayList::new)); + } +} diff --git a/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecordEntry.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecordEntry.java similarity index 75% rename from src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecordEntry.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecordEntry.java index 70bd6f1..03e68ba 100644 --- a/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecordEntry.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/client/model/PIDRecordEntry.java @@ -17,11 +17,21 @@ package edu.kit.datamanager.idoris.pids.client.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; /** * Represents a key-value pair in a SimplePidRecord. * This follows the PIDRecordEntry structure from the Typed PID Maker API. */ @JsonIgnoreProperties(ignoreUnknown = true) -public record PIDRecordEntry(String key, String value) { +public record PIDRecordEntry(PID key, String value) { + + public PIDRecordEntry(String key, String value) { + this(new PID(key), value); + } + + public PIDRecordEntry { + assert key != null; + assert value != null; + } } diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/dao/IPersistentIdentifierDao.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/dao/IPersistentIdentifierDao.java new file mode 100644 index 0000000..b40f75f --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/dao/IPersistentIdentifierDao.java @@ -0,0 +1,81 @@ +/* + * 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.idoris.pids.dao; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; +import edu.kit.datamanager.idoris.pids.domain.PIDNode; +import org.springframework.data.neo4j.repository.Neo4jRepository; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * Repository for PIDNode nodes. + */ +public interface IPersistentIdentifierDao extends Neo4jRepository, PagingAndSortingRepository { + + + /** + * Finds a PIDNode by the entity it identifies. + * + * @param entity The entity to find the PID for + * @return An Optional containing the PIDNode if found, or empty if not found + */ + Optional findByEntity(AdministrativeMetadata entity); + + /** + * Finds a PIDNode by the internal ID of the entity it identifies. + * + * @param entityInternalId The internal ID of the entity to find the PID for + * @return An Optional containing the PIDNode if found, or empty if not found + */ + Optional findByEntityInternalId(String entityInternalId); + + /** + * Finds all PersistentIdentifiers for entities of the given type. + * + * @param entityType The type of entity to find PIDs for + * @return A list of PersistentIdentifiers for entities of the given type + */ + List findByEntityType(String entityType); + + /** + * Finds all PersistentIdentifiers that are tombstones (entity has been deleted). + * + * @return A list of PersistentIdentifiers that are tombstones + */ + List findByTombstoneTrue(); + + /** + * Finds all PersistentIdentifiers that are not tombstones (entity has not been deleted). + * + * @return A list of PersistentIdentifiers that are not tombstones + */ + List findByTombstoneFalse(); + + /** + * Resolves the PID for an entity by its internalId via the IDENTIFIES relation. + * + * @param internalId entity internal id + * @return Optional PID string + */ + @Query("MATCH (p:PIDNode)-[:IDENTIFIES]->(e) WHERE e.internalId = $entityInternalId RETURN p") + List findPidsByEntityInternalId(@Param("entityInternalId") String internalId); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/domain/PIDNode.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/domain/PIDNode.java new file mode 100644 index 0000000..88acab0 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/domain/PIDNode.java @@ -0,0 +1,107 @@ +/* + * 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.idoris.pids.domain; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Version; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; + +import java.time.Instant; + +/** + * Entity representing a Persistent Identifier (PID) in the system. + * This is stored as a separate node in Neo4j and points to the entity it identifies. + */ +@Getter +@Setter +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Node("PIDNode") +public class PIDNode { + + /** + * The PID value, which is also the primary key of this entity. + */ + @Id + private PID pid; + + /** + * The type of entity this PID identifies. + */ + private String entityType; + + /** + * The internal ID of the entity this PID identifies. + */ + private String entityInternalId; + + /** + * Flag indicating whether this PID record is a tombstone (entity has been deleted). + */ + private boolean tombstone; + + /** + * Timestamp when the entity was deleted (only set if tombstone is true). + */ + private Instant deletedAt; + + /** + * Version of this PID record. + */ + @Version + private Long version; + + /** + * Timestamp when this PID record was created. + */ + @CreatedDate + private Instant createdAt; + + /** + * Timestamp when this PID record was last modified. + */ + @LastModifiedDate + private Instant lastModifiedAt; + + /** + * Relationship to the entity this PID identifies. + * This is null if the entity has been deleted (tombstone is true). + */ + @Relationship(value = "IDENTIFIES", direction = Relationship.Direction.OUTGOING) + private AdministrativeMetadata entity; + + /** + * Marks this PID record as a tombstone, indicating that the entity it identifies has been deleted. + * + * @param deletedAt The timestamp when the entity was deleted + * @return This PID record for method chaining + */ + public PIDNode markAsTombstone(Instant deletedAt) { + this.tombstone = true; + this.deletedAt = deletedAt; + this.entity = null; // Remove the relationship to the entity + return this; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/domain/TypedPIDMakerException.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/domain/TypedPIDMakerException.java new file mode 100644 index 0000000..334e6bb --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/domain/TypedPIDMakerException.java @@ -0,0 +1,46 @@ +/* + * 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.idoris.pids.domain; + +import org.springframework.http.ResponseEntity; + +public class TypedPIDMakerException extends RuntimeException { + ResponseEntity response; + + public TypedPIDMakerException(String message, Throwable cause) { + super(message, cause); + } + + public TypedPIDMakerException(ResponseEntity response) { + this.response = response; + this(response.toString()); + } + + public TypedPIDMakerException(String message) { + super(message); + } + + public TypedPIDMakerException(String message, ResponseEntity response) { + super(message); + this.response = response; + } + + public TypedPIDMakerException(String message, ResponseEntity response, Throwable cause) { + super(message, cause); + this.response = response; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/package-info.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/package-info.java new file mode 100644 index 0000000..7255166 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/package-info.java @@ -0,0 +1,29 @@ +/* + * 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. + */ + +/** + * PID module for IDORIS. + * This module is responsible for PID generation and management. + * It provides services for generating PIDs, creating PID records, and managing PID-related operations. + * + *

    The PID module depends on the core module for event infrastructure and the domain module for entity definitions. + * It listens for entity lifecycle events and generates PIDs for entities as needed.

    + */ +@org.springframework.modulith.ApplicationModule( + displayName = "IDORIS PID Management", + allowedDependencies = {"core"} +) +package edu.kit.datamanager.idoris.pids; \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/services/PersistentIdentifierService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/services/PersistentIdentifierService.java new file mode 100644 index 0000000..c65cb23 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/services/PersistentIdentifierService.java @@ -0,0 +1,341 @@ +/* + * 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.idoris.pids.services; + +import edu.kit.datamanager.idoris.core.configuration.TypedPIDMakerConfig; +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; +import edu.kit.datamanager.idoris.pids.api.IInternalPIDService; +import edu.kit.datamanager.idoris.pids.client.TypedPIDMakerClient; +import edu.kit.datamanager.idoris.pids.client.model.PIDRecord; +import edu.kit.datamanager.idoris.pids.client.model.PIDRecordEntry; +import edu.kit.datamanager.idoris.pids.dao.IPersistentIdentifierDao; +import edu.kit.datamanager.idoris.pids.domain.PIDNode; +import edu.kit.datamanager.idoris.pids.domain.TypedPIDMakerException; +import edu.kit.datamanager.idoris.pids.utils.PIDRecordMapper; +import edu.kit.datamanager.idoris.pids.web.v1.PidController; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NullMarked; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.Link; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +/** + * Service class for PIDNode entities. + * This class provides methods for creating, updating, and retrieving PIDNode entities. + */ +@Service +@Slf4j +@Observed(contextualName = "persistentIdentifierService") +@NullMarked +public class PersistentIdentifierService implements IInternalPIDService { + + private final IPersistentIdentifierDao repository; + private final TypedPIDMakerClient client; + private final TypedPIDMakerConfig config; + private final PIDRecordMapper mapper; + + /** + * Creates a new PersistentIdentifierService with the given dependencies. + * + * @param repository The repository for PIDNode entities + * @param client The client for the Typed PID Maker logic + * @param config The configuration for the Typed PID Maker logic + * @param mapper The mapper for converting between PIDNode and PIDRecord + */ + @Autowired + public PersistentIdentifierService(IPersistentIdentifierDao repository, + TypedPIDMakerClient client, + TypedPIDMakerConfig config, + PIDRecordMapper mapper) { + this.repository = repository; + this.client = client; + this.config = config; + this.mapper = mapper; + } + + /** + * Creates a new PIDNode for the given entity. + * This method creates a new PID record in the Typed PID Maker logic and stores a corresponding + * PIDNode entity in the local database. + * + * @param entity The entity to create a PID for + * @return The created PIDNode + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "persistentIdentifierService.createPersistentIdentifier", description = "Time taken to create a persistent identifier", histogram = true) + @Counted(value = "persistentIdentifierService.createPersistentIdentifier.count", description = "Number of persistent identifier creations") + public PIDNode createPersistentIdentifier(@SpanAttribute AdministrativeMetadata entity) { + log.debug("Creating PIDNode for entity: {}", entity); + + // Check if a PID already exists for this entity + Optional existingPid = repository.findByEntityInternalId(entity.getInternalId()); + if (existingPid.isPresent()) { + log.debug("PIDNode already exists for entity: {}", entity); + return existingPid.get(); + } + + // Create a temporary PID entity to use with the mapper + PIDNode tempPid = PIDNode.builder() + .pid(null) + .entityType(entity.getClass().getSimpleName()) + .entityInternalId(entity.getInternalId()) + .entity(entity) + .tombstone(false) + .build(); + + // Use the mapper to create a PID record with administrative metadata + PIDRecord record = mapper.toPIDRecord(tempPid); + + // Create the PID record in the Typed PID Maker logic + ResponseEntity createdResponse = client.createPIDRecord(record); + if (!createdResponse.getStatusCode().is2xxSuccessful()) { + log.error("Failed to create PID record in Typed PID Maker logic: {}", createdResponse.getStatusCode()); + throw new TypedPIDMakerException("Failed to create preliminary record in Typed PID Maker", createdResponse); + } + String etag = createdResponse.getHeaders().getETag(); + PIDRecord createdRecord = createdResponse.getBody(); + + log.debug("Created first PID record with PID {}: {}", Objects.requireNonNull(createdRecord).pid(), createdRecord); + + // Set the PID in the temporary PIDNode entity + tempPid.setPid(Objects.requireNonNull(createdRecord).pid()); + + // Save the PIDNode entity + PIDNode savedPid = repository.save(tempPid); + + // Update the entity with the saved PIDNode + List entries = createdRecord.record().stream() + .map(entry -> { + if (Objects.equals(entry.key(), "21.T11148/b8457812905b83046284")) { + // Update the DO location to point to the saved PID + String doLocation = String.format("%s/pid/%s", config.getBaseUrl(), createdRecord.pid()); + return new PIDRecordEntry(entry.key(), doLocation); + } + return entry; + }) + .toList(); + PIDRecord updatedRecord = new PIDRecord(createdRecord.pid(), entries); + // Update the PID record in the Typed PID Maker logic with the saved PID + log.debug("Updating PID record with saved PID: {}", updatedRecord); + + ResponseEntity updatedResponse = client.updatePIDRecord(savedPid.getPid().toString(), updatedRecord, etag); + if (!updatedResponse.getStatusCode().is2xxSuccessful()) { + log.error("Failed to update PID record in Typed PID Maker logic: {}", updatedResponse.getStatusCode()); + throw new TypedPIDMakerException("Failed to update PID record in Typed PID Maker logic", updatedResponse); + } + + log.info("Created PIDNode: {} with record", savedPid); + return savedPid; + } + + /** + * Marks the PIDNode for the given entity as a tombstone. + * This method updates the PID record in the Typed PID Maker logic to indicate that the entity has been deleted. + * + * @param entity The entity that has been deleted + * @return The updated PIDNode, or empty if no PID exists for the entity + */ + @Transactional + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "persistentIdentifierService.markAsTombstone", description = "Time taken to mark persistent identifier as tombstone", histogram = true) + @Counted(value = "persistentIdentifierService.markAsTombstone.count", description = "Number of persistent identifiers marked as tombstone") + public Optional markAsTombstone(@SpanAttribute AdministrativeMetadata entity) { + log.debug("Marking PIDNode as tombstone for entity: {}", entity); + + // Find the PID for the entity + Optional optionalPid = repository.findByEntityInternalId(entity.getInternalId()); + if (optionalPid.isEmpty()) { + log.warn("No PIDNode found for entity: {}", entity); + return Optional.empty(); + } + + PIDNode pid = optionalPid.get(); + + // Mark the PID as a tombstone + pid.markAsTombstone(Instant.now()); + + // Save the updated PID + PIDNode savedPid = repository.save(pid); + + // Update the PID record in the Typed PID Maker logic + updatePIDRecord(savedPid); + + log.info("Marked PIDNode as tombstone: {}", savedPid); + return Optional.of(savedPid); + } + + /** + * Updates the PID record for the given PIDNode. + * This method updates the PID record in the Typed PID Maker logic with the latest metadata from the entity. + * + * @param pid The PIDNode to update the PID record for + * @return The updated PIDNode + */ + @Transactional + @WithSpan(kind = SpanKind.CLIENT) + @Timed(value = "persistentIdentifierService.updatePIDRecord", description = "Time taken to update PID record", histogram = true) + @Counted(value = "persistentIdentifierService.updatePIDRecord.count", description = "Number of PID record updates") + public PIDNode updatePIDRecord(@SpanAttribute PIDNode pid) { + log.debug("Updating PID record for PIDNode: {}", pid); + + // Create a PID record with metadata from the entity + PIDRecord record = mapper.toPIDRecord(pid); + + // Retrieve current record (e.g., to ensure existence) and then update + ResponseEntity getResponse = client.getPIDRecord(pid.getPid().get()); + if (!getResponse.getStatusCode().is2xxSuccessful() || getResponse.getBody() == null) { + log.error("Failed to retrieve PID record from Typed PID Maker logic: {}", getResponse.getStatusCode()); + throw new TypedPIDMakerException("Failed to retrieve PID record from Typed PID Maker logic", getResponse); + } + PIDRecord remote = getResponse.getBody(); + if (record == null || !pid.getPid().equals(record.pid()) || !pid.getPid().equals(remote.pid())) { + log.error("PID record is null or PID does not match: expected {}, got {} and remote {}", pid.getPid(), record != null ? record.pid() : "null", remote.pid()); + throw new IllegalArgumentException("PID record is null or PID does not match"); + } + String etag = getResponse.getHeaders().getETag(); + + // Update the PID record in the Typed PID Maker logic + ResponseEntity updatedResponse = client.updatePIDRecord(record.pid().get(), record, etag); + if (!updatedResponse.getStatusCode().is2xxSuccessful()) { + log.error("Failed to update PID record in Typed PID Maker logic: {}", updatedResponse.getStatusCode()); + throw new TypedPIDMakerException("Failed to update PID record in Typed PID Maker logic", updatedResponse); + } + + log.info("Updated PID record for PIDNode: {}", pid); + return pid; + } + + /** + * Gets the PIDNode for the given entity. + * + * @param entity The entity to get the PID for + * @return An Optional containing the PIDNode if found, or empty if not found + */ + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "persistentIdentifierService.getPersistentIdentifierByEntity", description = "Time taken to get persistent identifier by entity", histogram = true) + @Counted(value = "persistentIdentifierService.getPersistentIdentifierByEntity.count", description = "Number of get persistent identifier by entity requests") + public Optional getPersistentIdentifier(@SpanAttribute AdministrativeMetadata entity) { + log.debug("Getting PIDNode for entity: {}", entity); + return repository.findByEntityInternalId(entity.getInternalId()); + } + + /** + * Gets the PIDNode with the given PID. + * + * @param pid The PID to get the PIDNode for + * @return An Optional containing the PIDNode if found, or empty if not found + */ + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "persistentIdentifierService.getPersistentIdentifierByPid", description = "Time taken to get persistent identifier by PID", histogram = true) + @Counted(value = "persistentIdentifierService.getPersistentIdentifierByPid.count", description = "Number of get persistent identifier by PID requests") + public Optional getPersistentIdentifier(@SpanAttribute("pid.value") String pid) { + log.debug("Getting PIDNode with PID: {}", pid); + return repository.findById(new PID(pid)); + } + + /** + * Gets all PersistentIdentifiers. + * + * @return A list of all PersistentIdentifiers + */ + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "persistentIdentifierService.getAllPersistentIdentifiers", description = "Time taken to get all persistent identifiers", histogram = true) + @Counted(value = "persistentIdentifierService.getAllPersistentIdentifiers.count", description = "Number of get all persistent identifiers requests") + public List getAllPersistentIdentifiers() { + log.debug("Getting all PersistentIdentifiers"); + List pids = repository.findAll(); + log.info("Retrieved {} persistent identifiers", pids.size()); + return pids; + } + + /** + * Gets all PersistentIdentifiers for entities of the given type. + * + * @param entityType The type of entity to get PIDs for + * @return A list of PersistentIdentifiers for entities of the given type + */ + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "persistentIdentifierService.getPersistentIdentifiersByEntityType", description = "Time taken to get persistent identifiers by entity type", histogram = true) + @Counted(value = "persistentIdentifierService.getPersistentIdentifiersByEntityType.count", description = "Number of get persistent identifiers by entity type requests") + public List getPersistentIdentifiersByEntityType(@SpanAttribute("entity.type") String entityType) { + log.debug("Getting PersistentIdentifiers for entity type: {}", entityType); + List pids = repository.findByEntityType(entityType); + log.info("Retrieved {} persistent identifiers for entity type: {}", pids.size(), entityType); + return pids; + } + + /** + * Gets all PersistentIdentifiers that are tombstones (entity has been deleted). + * + * @return A list of PersistentIdentifiers that are tombstones + */ + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "persistentIdentifierService.getTombstones", description = "Time taken to get tombstone persistent identifiers", histogram = true) + @Counted(value = "persistentIdentifierService.getTombstones.count", description = "Number of get tombstone persistent identifiers requests") + public List getTombstones() { + log.debug("Getting tombstone PersistentIdentifiers"); + List tombstones = repository.findByTombstoneTrue(); + log.info("Retrieved {} tombstone persistent identifiers", tombstones.size()); + return tombstones; + } + + @Override + public List getPIDAssociatedWithInternalID(String internalId) { + if (internalId.isBlank()) return List.of(); + log.debug("Getting PIDNode for internalId: {}", internalId); + return repository.findPidsByEntityInternalId(internalId).stream().map(PIDNode::getPid).toList(); + } + + @Override + public List getPIDLinkForInternalID(String internalId) { + if (internalId.isBlank()) return List.of(); + List pids = getPIDAssociatedWithInternalID(internalId); + if (!pids.isEmpty()) { + return pids.stream() + .map(this::getLinkForPID) + .toList(); + } + return List.of(); + } + + @Override + public Link getLinkForPID(PID pid) { + String endpoint = linkTo(methodOn(PidController.class).redirectToEntity(null, null)).toUri().toString(); + endpoint = endpoint.replace("{?pid}", ""); + endpoint = endpoint.replace("**", pid.toString()); + log.trace("Generated Link for PID: {} -> {}", pid, endpoint); + return Link.of(endpoint).withRel("pid"); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/utils/PIDRecordMapper.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/utils/PIDRecordMapper.java new file mode 100644 index 0000000..093213e --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/utils/PIDRecordMapper.java @@ -0,0 +1,196 @@ +/* + * 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.idoris.pids.utils; + +import edu.kit.datamanager.idoris.core.configuration.ApplicationProperties; +import edu.kit.datamanager.idoris.core.configuration.TypedPIDMakerConfig; +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import edu.kit.datamanager.idoris.core.domain.valueObjects.PID; +import edu.kit.datamanager.idoris.pids.client.model.PIDRecord; +import edu.kit.datamanager.idoris.pids.client.model.PIDRecordEntry; +import edu.kit.datamanager.idoris.pids.domain.PIDNode; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Utility class for mapping between PIDNode entities and PIDRecord objects. + * This class extracts the mapping logic to reduce redundancies. + */ +@Component +@Slf4j +@Observed(contextualName = "pidRecordMapper") +public class PIDRecordMapper { + + private final ApplicationProperties applicationProperties; + private final TypedPIDMakerConfig config; + + /** + * Creates a new PIDRecordMapper with the given dependencies. + * + * @param applicationProperties The application properties + * @param config The configuration for the Typed PID Maker logic + */ + @Autowired + public PIDRecordMapper(ApplicationProperties applicationProperties, TypedPIDMakerConfig config) { + this.applicationProperties = applicationProperties; + this.config = config; + } + + /** + * Converts a PIDNode to a PIDRecord. + * This method creates a PIDRecord with metadata from the PIDNode and its associated entity. + * + * @param pid The PIDNode to convert + * @return The converted PIDRecord + */ + @WithSpan(kind = SpanKind.INTERNAL) + public PIDRecord toPIDRecord(@SpanAttribute PIDNode pid) { + log.debug("Converting PIDNode to PIDRecord: {}", pid.getPid()); + List recordEntries = new ArrayList<>(); + AdministrativeMetadata entity = pid.getEntity(); + + // Helmholtz Kernel Information Profile + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/076759916209e5d62bd5"), "21.T11148/b9b76f887845e32d29f7")); + + // Always add a pointer to the entity + String baseUrl = getBaseUrl(); + String doLocation; + + if (pid.isTombstone()) { + // For tombstones, use a special URL that indicates the entity has been deleted + doLocation = String.format("%s/tombstone/%s", baseUrl, pid.getPid()); + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/d1ec8ccbfa6de41da894"), "TOMBSTONE")); //TODO: Add more tombstone information +// recordEntries.add(new PIDRecordEntry("deletedAt", pid.getDeletedAt().toString())); + } else { + // For active entities, use a URL that points to the entity + doLocation = String.format("%s/pid/%s", baseUrl, pid.getPid()); + } + + log.debug("Using DO location: {}", doLocation); + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/b8457812905b83046284"), doLocation)); + + // Add entity type information as digitalObjectType (currently hardcoded to "application/json") + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/1c699a5d1b4ad3ba4956"), "21.T11148/ca9fd0b2414177b79ac2")); + + // Add CC0 license information + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/2f314c8fe5fb6a0063a8"), "https://spdx.org/license/CC0-1.0/")); + + // Add nested SHA-256 hash for the doLocation + String sha256Hash = ""; + try { + // Calculate the SHA-256 hash of the doLocation as hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : MessageDigest.getInstance("SHA-256").digest(doLocation.getBytes(StandardCharsets.UTF_8))) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + sha256Hash = hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/82e2503c49209e987740"), String.format("{\"sha256sum\": \"sha256 %s\"}", sha256Hash))); + + // Add basic metadata + if (entity.getName() != null) { + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/6ae999552a0d2dca14d6"), entity.getName().toString())); + } + + // Add timestamps + Instant createdAt = entity.getCreatedAt(); + if (createdAt != null) { + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/aafd5fb4c7222e2d950a"), createdAt.toString())); + } + + Instant lastModifiedAt = entity.getLastModifiedAt(); + if (lastModifiedAt != null) { + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/397d831aa3a9d18eb52c"), lastModifiedAt.toString())); + } + + // Add version information + Long version = entity.getVersion(); + if (version != null) { + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/c692273deb2772da307f"), "v" + version)); + } + + // Add contributors + if (entity.getContributors() != null && !entity.getContributors().isEmpty()) { + entity.getContributors().forEach(contributor -> { + URL orcidURL = contributor.getOrcid().get(); + if (orcidURL != null && orcidURL.getHost() != null && (orcidURL.getHost().equals("orcid.org") || orcidURL.getHost().endsWith(".orcid.org"))) { + recordEntries.add(new PIDRecordEntry(new PID("21.T11148/1a73af9e7ae00182733b"), orcidURL.toExternalForm())); + log.debug("Added ORCiD URL: {}", orcidURL); + } else { + log.info("This contributor does not have a URL, skipping: {}", contributor); + } + }); + } + + // Add references + if (entity.getReferences() != null && !entity.getReferences().isEmpty()) { + entity.getReferences().forEach(reference -> { + PID relationPID = reference.relationType(); + PID targetPID = reference.targetPID(); + if (relationPID != null && targetPID != null) { + recordEntries.add(new PIDRecordEntry(relationPID, targetPID.toString())); + log.debug("Added reference: {} -> {}", relationPID, targetPID); + } else { + log.warn("Invalid reference found, skipping: {}", reference); + } + }); + } + + // Create the PID record + PIDRecord pidRecord = new PIDRecord(pid.getPid(), recordEntries); + log.debug("Created PIDRecord: {}", pidRecord); + return pidRecord; + } + + /** + * Gets the base URL from the application properties. + * This method ensures that the base URL does not end with a slash. + * + * @return The base URL + */ + @WithSpan(kind = SpanKind.INTERNAL) + private String getBaseUrl() { + log.debug("Getting base URL from application properties"); + String baseUrl = applicationProperties.getBaseUrl(); + if (baseUrl == null || baseUrl.trim().isEmpty()) { + log.error("Base URL is not configured or is empty."); + throw new IllegalArgumentException("Base URL must be configured."); + } + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + log.debug("Using base URL: {}", baseUrl); + return baseUrl; + } +} \ No newline at end of file diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/utils/PidLinkResolver.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/utils/PidLinkResolver.java new file mode 100644 index 0000000..464f49e --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/utils/PidLinkResolver.java @@ -0,0 +1,48 @@ +/* + * 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.idoris.pids.utils; + +import edu.kit.datamanager.idoris.core.domain.AdministrativeMetadata; +import edu.kit.datamanager.idoris.pids.domain.PIDNode; +import edu.kit.datamanager.idoris.pids.services.PersistentIdentifierService; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * Utility component to resolve a PID link ("/pid/{pid}") for a given entity. + * Returns null when no PID is available yet. + */ +@Component +public class PidLinkResolver { + private final PersistentIdentifierService pidService; + + public PidLinkResolver(PersistentIdentifierService pidService) { + this.pidService = pidService; + } + + /** + * Resolves a relative link to the PID endpoint for the given entity. + * + * @param entity AdministrativeMetadata entity + * @return Link string like "/pid/{pid}" or null if no PID exists yet + */ + public String linkFor(AdministrativeMetadata entity) { + if (entity == null) return null; + Optional opt = pidService.getPersistentIdentifier(entity); + return opt.map(pid -> "/pid/" + pid.getPid()).orElse(null); + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/web/api/IPidApi.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/web/api/IPidApi.java new file mode 100644 index 0000000..d5481d8 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/web/api/IPidApi.java @@ -0,0 +1,102 @@ +/* + * 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.idoris.pids.web.api; + +import edu.kit.datamanager.idoris.pids.domain.PIDNode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.WebRequest; + +/** + * API interface for PID-related operations. + * This interface defines the REST API for accessing Persistent Identifiers (PIDs). + */ +@RestController +@RequestMapping(value = "/api/v1/pid") +@Tag(name = "Persistent Identifier", description = "API for accessing Persistent Identifiers (PIDs)") +public interface IPidApi { + + /** + * Lists all PIDs exposed by IDORIS. + * + * @return A collection of all PersistentIdentifiers + */ + @GetMapping + @Operation( + summary = "Get all Persistent Identifiers", + description = "Returns a collection of all Persistent Identifiers exposed by IDORIS", + responses = { + @ApiResponse(responseCode = "200", description = "Persistent Identifiers found", + content = @Content(mediaType = "application/hal+json", + schema = @Schema(implementation = PIDNode.class))) + } + ) + ResponseEntity>> getAllPersistentIdentifiers(); + + /** + * Redirects to the appropriate entity based on the PID value. + * If the PID is a tombstone, redirects to the tombstone page. + * Otherwise, redirects to the entity's page. + * + * @param pidValue The PID value to redirect to + * @return A ResponseEntity with a redirect status or not found if no entity is found + */ + @GetMapping("/**") + @Operation( + summary = "Redirect to entity by PID", + description = "Redirects to the appropriate entity based on the PID value. If the PID is a tombstone, redirects to the tombstone page.", + responses = { + @ApiResponse(responseCode = "302", description = "Found - Redirect to entity or tombstone"), + @ApiResponse(responseCode = "404", description = "PID not found") + } + ) + ResponseEntity redirectToEntity( + @Parameter(description = "PID of the entity", required = true) + @RequestParam(value = "pid", required = false) String pid, WebRequest request); + + /** + * Handles requests to the tombstone page. + * Returns a 410 Gone status with information about the deleted entity. + * + * @param pidValue The PID value of the tombstone + * @return A ResponseEntity with a 410 Gone status and information about the deleted entity + */ + @GetMapping("/tombstone/**") + @Operation( + summary = "Get tombstone information", + description = "Returns tombstone information for a deleted entity", + responses = { + @ApiResponse(responseCode = "410", description = "Gone - Entity has been deleted", + content = @Content(mediaType = "text/plain")), + @ApiResponse(responseCode = "302", description = "Found - Redirect to entity (if not a tombstone)"), + @ApiResponse(responseCode = "404", description = "PID not found") + } + ) + ResponseEntity handleTombstone( + @Parameter(description = "PID value of the tombstone", required = true) + @RequestParam(value = "pid", required = false) String pid, WebRequest request); +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/web/hateoas/PersistentIdentifierModelAssembler.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/web/hateoas/PersistentIdentifierModelAssembler.java new file mode 100644 index 0000000..9712132 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/web/hateoas/PersistentIdentifierModelAssembler.java @@ -0,0 +1,71 @@ +/* + * 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.idoris.pids.web.hateoas; + +import edu.kit.datamanager.idoris.pids.domain.PIDNode; +import edu.kit.datamanager.idoris.pids.web.v1.PidController; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.hateoas.server.RepresentationModelProcessor; +import org.springframework.stereotype.Component; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +/** + * A model assembler for PIDNode entities. + * This class converts PIDNode entities to EntityModel + * with HATEOAS links. + *

    + * This class combines the functionality of both a RepresentationModelAssembler and a + * RepresentationModelProcessor, handling all HATEOAS concerns for PIDNode entities + * in one place, according to Domain-Driven Design principles. + */ +@Component +public class PersistentIdentifierModelAssembler implements + RepresentationModelAssembler>, + RepresentationModelProcessor> { + + @Override + public EntityModel toModel(PIDNode pid) { + EntityModel pidModel = EntityModel.of(pid); + + // Add self link + pidModel.add(linkTo(methodOn(PidController.class).getAllPersistentIdentifiers()).withRel("persistentIdentifiers")); + + return pidModel; + } + + @Override + public EntityModel process(EntityModel model) { + PIDNode pid = model.getContent(); + if (pid == null) { + return model; + } + + String pidValue = pid.getPid().toString(); + + // Add link to resolve the entity + model.add(linkTo(methodOn(PidController.class).redirectToEntity(pidValue, null)).withRel("resolve")); + + // Add link to tombstone if it is a tombstone + if (pid.isTombstone()) { + model.add(linkTo(methodOn(PidController.class).handleTombstone(pidValue, null)).withRel("tombstone")); + } + + return model; + } +} diff --git a/idoris/src/main/java/edu/kit/datamanager/idoris/pids/web/v1/PidController.java b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/web/v1/PidController.java new file mode 100644 index 0000000..296c446 --- /dev/null +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/pids/web/v1/PidController.java @@ -0,0 +1,199 @@ +/* + * 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.idoris.pids.web.v1; + +import edu.kit.datamanager.idoris.pids.domain.PIDNode; +import edu.kit.datamanager.idoris.pids.services.PersistentIdentifierService; +import edu.kit.datamanager.idoris.pids.web.api.IPidApi; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +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.server.ResponseStatusException; +import org.springframework.web.servlet.HandlerMapping; + +import java.net.URI; +import java.util.Locale; +import java.util.Optional; + +/** + * Controller for PID-related operations. + * This controller handles: + * - /pid - Lists all PIDs exposed by IDORIS + * - /pid/{pidValue} - Redirects to the entity the PID refers to + * - /pid/tombstone/{pidValue} - Handles tombstone pages for deleted entities + */ +@RestController +@Slf4j +@Observed(contextualName = "pidController") +//@Tag(name = "Persistent Identifier", description = "API for accessing Persistent Identifiers (PIDs)") +public class PidController implements IPidApi { + + private final PersistentIdentifierService pidService; + + /** + * Creates a new PidController with the given dependencies. + * + * @param pidService The PersistentIdentifierService + */ + @Autowired + public PidController(PersistentIdentifierService pidService) { + this.pidService = pidService; + } + + /** + * Lists all PIDs exposed by IDORIS. + * + * @return A collection of all PersistentIdentifiers + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "pidController.getAllPersistentIdentifiers", description = "Time taken to get all persistent identifiers", histogram = true) + @Counted(value = "pidController.getAllPersistentIdentifiers.count", description = "Number of get all persistent identifiers requests") + public ResponseEntity>> getAllPersistentIdentifiers() { + log.debug("Getting all PersistentIdentifiers"); + CollectionModel> pids = CollectionModel.of(pidService.getAllPersistentIdentifiers().stream().map(EntityModel::of).toList()); + log.info("Retrieved {} persistent identifiers", pids.getContent().size()); + return ResponseEntity.ok(pids); + } + + /** + * Redirects to the appropriate entity based on the PID value. + * If the PID is a tombstone, redirects to the tombstone page. + * Otherwise, redirects to the entity's page. + * + * @param pid The PID value to redirect to + * @return A ResponseEntity with a redirect status or not found if no entity is found + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "pidController.redirectToEntity", description = "Time taken to redirect to entity", histogram = true) + @Counted(value = "pidController.redirectToEntity.count", description = "Number of redirect to entity requests") + public ResponseEntity redirectToEntity(@SpanAttribute("pid.value") String pid, WebRequest request) { + String pidValue = getContentPathFromRequest("pid", request, pid); + + log.debug("Redirecting to pid {}", pidValue); + + // Get the PIDNode for the given PID + Optional optionalPid = pidService.getPersistentIdentifier(pidValue); + if (optionalPid.isEmpty()) { + log.warn("No PIDNode found for PID: {}", pidValue); + return ResponseEntity.notFound().build(); + } + + PIDNode pidNode = optionalPid.get(); + + // If the PID is a tombstone, redirect to the tombstone page + if (pidNode.isTombstone()) { + log.debug("PID is a tombstone, redirecting to tombstone page: {}", pidValue); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create("/tombstone/" + pidValue)) + .build(); + } + + // If the PID has an entity, redirect to the entity's page + if (pidNode.getEntity() != null) { + String entityType = pidNode.getEntityType().toLowerCase(Locale.ROOT) + "s"; + String entityId = pidNode.getEntityInternalId(); + log.debug("Redirecting to entity: {}/{}", entityType, entityId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create("/api/v1/" + entityType + "/" + entityId)) + .build(); + } + + // If the PID has no entity and is not a tombstone, return not found + log.warn("PID has no entity and is not a tombstone: {}", pidValue); + return ResponseEntity.notFound().build(); + } + + /** + * Extracts and returns the content path from the incoming web request, based on the specified last path element. + * + * @param lastPathElement the last path element used to determine the content path + * @param request the incoming web request containing the requested URI and attributes + * @return the extracted content path from the request + * @throws IllegalArgumentException if the requested URI cannot be obtained from the web request + */ + @WithSpan(kind = SpanKind.INTERNAL) + private String getContentPathFromRequest(String lastPathElement, WebRequest request, String alternativeInput) { + String requestedUri = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, + RequestAttributes.SCOPE_REQUEST); + String result = ""; + if (requestedUri != null) { + log.debug("Requested URI: {}", requestedUri); + result = requestedUri + .substring(requestedUri.indexOf(lastPathElement + "/") + (lastPathElement + "/").length()) + .replace("**", ""); + } + + if (result.isBlank()) { + log.debug("Requested URI is blank. Using alternative input: {}", alternativeInput); + result = alternativeInput; + } + + result = result.replace("%2F", "/"); + result = result.replace("**", ""); + + return result; + } + + /** + * Handles requests to the tombstone page. + * Returns a 410 Gone status with information about the deleted entity. + * + * @param pid The PID value of the tombstone + * @return A ResponseEntity with a 410 Gone status and information about the deleted entity + */ + @Override + @WithSpan(kind = SpanKind.SERVER) + @Timed(value = "pidController.handleTombstone", description = "Time taken to handle tombstone request", histogram = true) + @Counted(value = "pidController.handleTombstone.count", description = "Number of tombstone requests") + public ResponseEntity handleTombstone(@SpanAttribute("pid.value") String pid, WebRequest request) { + String pidValue = getContentPathFromRequest("pid", request, pid); + + log.debug("Handling tombstone request for PID: {}", pidValue); + + // Get the PIDNode for the given PID + PIDNode pidNode = pidService.getPersistentIdentifier(pidValue).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "PID not found: " + pidValue)); + + // If the PID is not a tombstone, redirect to the entity's page + if (!pidNode.isTombstone()) { + log.debug("PID is not a tombstone, redirecting to entity page: {}", pidValue); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create("/pid/" + pidValue)) + .build(); + } + + // Return a 410 Gone status with information about the deleted entity + String message = String.format("The entity with PID %s has been deleted at %s. Entity type: %s", + pidNode.getPid(), pidNode.getDeletedAt(), pidNode.getEntityType()); + log.debug("Returning tombstone message: {}", message); + log.info("Served tombstone for PID: {}", pidValue); + return ResponseEntity.status(HttpStatus.GONE) + .body(message); + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/idoris/rules/logic/RuleService.java b/idoris/src/main/java/edu/kit/datamanager/idoris/rules/logic/RuleService.java similarity index 56% rename from src/main/java/edu/kit/datamanager/idoris/rules/logic/RuleService.java rename to idoris/src/main/java/edu/kit/datamanager/idoris/rules/logic/RuleService.java index d44f4d5..6da7a0c 100644 --- a/src/main/java/edu/kit/datamanager/idoris/rules/logic/RuleService.java +++ b/idoris/src/main/java/edu/kit/datamanager/idoris/rules/logic/RuleService.java @@ -16,66 +16,30 @@ package edu.kit.datamanager.idoris.rules.logic; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.observation.annotation.Observed; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.annotations.WithSpan; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ListableBeanFactory; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; -import java.lang.reflect.Array; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; +import java.util.regex.Matcher; import java.util.stream.Collectors; -/** - * Central service that manages rule discovery and execution based on precomputed dependency graphs. - *

    - * The RuleService is the orchestration core of the rule execution engine. It leverages a - * precomputed dependency graph generated at compile-time by the annotation processor to - * efficiently execute rules in the correct order. This approach eliminates the overhead - * of runtime dependency resolution and enables optimal parallel execution. - *

    - * This service follows an optimization-first design with several key principles: - *

      - *
    • Just-in-time rule instantiation: Only rules referenced in the precomputed - * graph are loaded, reducing memory usage and startup time
    • - *
    • Zero runtime dependency calculation: All rule ordering is determined - * at compile-time through static analysis
    • - *
    • Maximum parallelism: Rules are executed concurrently using CompletableFuture - * while still respecting their execution order
    • - *
    • Type-safe execution: Strong generic typing ensures rules receive - * compatible input types and produce correct output types
    • - *
    • Resilient processing: Failures in individual rules are isolated and won't - * cause the entire rule processing pipeline to fail
    • - *
    - *

    - * Usage example: - *

    - * {@code
    - * // Create a rule result factory
    - * Supplier resultFactory = ValidationResult::new;
    - *
    - * // Execute validation rules for an Operation
    - * ValidationResult result = ruleService.executeRules(
    - *     RuleTask.VALIDATE,
    - *     operation,
    - *     resultFactory
    - * );
    - * }
    - * 
    - *

    - * Extension points: The rule engine can be extended by implementing the {@link IRule} - * interface and annotating the implementation with {@link Rule}. The annotation processor will - * automatically incorporate the new rule into the precomputed graph. - */ -@Component -@RequiredArgsConstructor +@Service @Slf4j -public class RuleService { +@Observed(contextualName = "ruleService") +public class RuleService implements IRuleService { /** * Provides access to all Spring-managed beans for rule discovery. @@ -83,8 +47,11 @@ public class RuleService { * Used to selectively load only the rule implementations that are actually referenced * in the precomputed dependency graph, avoiding the instantiation of unused rules. */ - private final ListableBeanFactory beanFactory; +// private final ListableBeanFactory beanFactory; +// +// private final ApplicationContext context; + private final ObjectProvider>> allRules; /** * Thread-safe registry mapping fully qualified class names to rule instances. *

    @@ -105,6 +72,10 @@ public class RuleService { */ private PrecomputedRuleGraph precomputedGraph; + public RuleService(ObjectProvider>> allRules) { + this.allRules = allRules; + } + /** * Initializes the rule engine by loading the precomputed dependency graph and * discovering required rule implementations. @@ -159,6 +130,7 @@ void initialize() { * which could happen if the annotation processor didn't run * or if the generated class is not on the classpath */ + @WithSpan(kind = SpanKind.INTERNAL) private void loadPrecomputedGraph() { log.info("Loading precomputed rule dependency graph..."); @@ -193,6 +165,7 @@ private void loadPrecomputedGraph() { * can help identify configuration issues early during application startup rather than * failing at runtime. */ + @WithSpan(kind = SpanKind.INTERNAL) private void discoverRequiredRules() { log.info("Discovering required rule implementations..."); @@ -212,19 +185,19 @@ private void discoverRequiredRules() { log.debug("Precomputed graph references {} unique rule classes", requiredRuleClasses.size()); // Find and register only the required rule beans - beanFactory.getBeansOfType(IRule.class) - .forEach((beanName, ruleBean) -> { - String className = ruleBean.getClass().getName(); - - // Only register if this rule is referenced in the precomputed graph - if (requiredRuleClasses.containsKey(className)) { - ruleRegistry.put(className, ruleBean); - requiredRuleClasses.put(className, true); // mark as found - log.debug("Registered required rule: {}", className); - } else { - log.debug("Skipping unreferenced rule: {}", className); - } - }); + Objects.requireNonNull(allRules.getIfAvailable()).forEach(rule -> { + String className = rule.getClass().getName() + .replaceAll(Matcher.quoteReplacement("$$.*") + "$", "") + .replaceAll("@.+$", ""); + // Only register if this rule is referenced in the precomputed graph + if (requiredRuleClasses.containsKey(className)) { + ruleRegistry.put(className, rule); + requiredRuleClasses.put(className, true); // mark as found + log.debug("Registered required rule: {}", className); + } else { + log.debug("Skipping unreferenced rule: {}", className); + } + }); // Log any missing rules long missingRules = requiredRuleClasses.values().stream() @@ -249,35 +222,17 @@ private void discoverRequiredRules() { *

    * This method is the primary entry point for rule execution. It retrieves the correct * sequence of rules from the precomputed graph based on the task and element type, - * then executes them in parallel while respecting their dependency order. + * then executes them sequentially while maintaining OpenTelemetry span context. *

    * The method follows these steps: *

      *
    1. Identify the correct set of rule class names from the precomputed graph
    2. - *
    3. Execute those rules in parallel using {@link CompletableFuture}
    4. + *
    5. Execute those rules sequentially in their dependency order
    6. *
    7. Merge the results from all rules into a single result object
    8. *
    *

    * If no rules are found for the given task and element type, an empty result is returned. *

    - * Example usage: - *

    -     * {@code
    -     * // Validate an Operation
    -     * ValidationResult validationResult = ruleService.executeRules(
    -     *     RuleTask.VALIDATE,
    -     *     operation,
    -     *     ValidationResult::new
    -     * );
    -     *
    -     * // Enrich a TypeProfile
    -     * EnrichmentResult enrichmentResult = ruleService.executeRules(
    -     *     RuleTask.ENRICH,
    -     *     typeProfile,
    -     *     EnrichmentResult::new
    -     * );
    -     * }
    -     * 
    * * @param task the rule task to execute (e.g., {@link RuleTask#VALIDATE}) * @param element the domain element to process @@ -288,8 +243,11 @@ private void discoverRequiredRules() { * @throws RuntimeException if a critical error occurs during rule execution that prevents * completion of the operation */ + @WithSpan(kind = SpanKind.INTERNAL) + @Timed(value = "rules.ruleService.executeRules", description = "Time to execute all rules for a given task/element", histogram = true) + @Counted(value = "rules.ruleService.executeRules.count", description = "Rule execution entrypoints") public > R executeRules( - RuleTask task, + @SpanAttribute RuleTask task, T element, Supplier resultFactory ) { @@ -306,23 +264,35 @@ public > R executeRules( log.debug("Found {} rules for task={}, elementType={}", ruleClassNames.size(), task, elementType); - // Execute rules in parallel and merge results - return executeRulesInParallel(ruleClassNames, element, resultFactory); + // Execute rules sequentially to maintain proper dependency order and OpenTelemetry context + return executeRulesSequentially(ruleClassNames, element, resultFactory); + } + + @WithSpan(kind = SpanKind.INTERNAL) + @Override + public > R executeSpecificRule(String ruleName, T element, Supplier resultFactory) throws RuntimeException { + return executeRulesSequentially(List.of(ruleName), element, resultFactory); + } + + @WithSpan(kind = SpanKind.INTERNAL) + @Override + public > R executeSpecificRule(IRule rule, T element, Supplier resultFactory) throws RuntimeException { + return executeRule(rule, element, resultFactory); } /** - * Executes rules in parallel while respecting their precomputed ordering. + * Executes rules sequentially in their precomputed ordering. *

    - * This method is responsible for the actual parallel execution of rules. It takes the + * This method is responsible for the sequential execution of rules. It takes the * list of rule class names in their precomputed execution order, retrieves the rule - * instances from the registry, and executes them concurrently. + * instances from the registry, and executes them one by one while maintaining + * the OpenTelemetry span context. *

    * Key aspects of this implementation: *

      *
    • Selective execution: Only rules that are found in the registry are executed
    • - *
    • Parallel processing: Each rule executes in its own {@link CompletableFuture}
    • - *
    • Coordinated completion: The method waits for all rule executions to complete
    • - *
    • Result aggregation: Results from all rules are merged into a single result
    • + *
    • Sequential processing: Each rule executes in order, maintaining span context
    • + *
    • Result accumulation: Results from all rules are merged into a single result
    • *
    *

    * If no rule instances are available for execution, an empty result is returned. This ensures @@ -336,41 +306,50 @@ public > R executeRules( * @return the merged result of all executed rules * @throws RuntimeException if rule execution fails critically */ - private > R executeRulesInParallel( - List ruleClassNames, + @SuppressWarnings("unchecked") + @WithSpan(kind = SpanKind.INTERNAL) + private > R executeRulesSequentially( + @SpanAttribute List ruleClassNames, T element, Supplier resultFactory ) { - // Create result futures for rule execution, but only for rules that are actually available in the registry - // This pipeline: 1) Gets rule instances from registry, 2) Filters out missing rules, 3) Executes each rule asynchronously - List> resultFutures = ruleClassNames.stream() - .map(ruleRegistry::get) // Look up each rule by class name - .filter(Objects::nonNull) // Skip rules that weren't found (null) - .map(rule -> executeRule(rule, element, resultFactory)) // Execute each rule asynchronously - .collect(Collectors.toList()); // Collect all future results - - if (resultFutures.isEmpty()) { - log.debug("No available rule instances found for execution"); - return resultFactory.get(); - } + R finalResult = resultFactory.get(); - // Wait for all executions to complete - try { - CompletableFuture.allOf(resultFutures.toArray(CompletableFuture[]::new)).join(); - } catch (Exception e) { - log.error("Error during rule execution", e); - throw new RuntimeException("Rule execution failed", e); + for (String ruleClassName : ruleClassNames) { + IRule rule = getRuleFromRegistry(ruleClassName); + if (rule != null) { + try { + R ruleResult = executeRule(rule, element, resultFactory); + finalResult = finalResult.merge(ruleResult); + } catch (Exception e) { + log.error("Rule {} execution failed: {}", rule.getClass().getSimpleName(), e.getMessage(), e); + throw new RuntimeException("Rule execution failed: " + e.getMessage(), e); + } + } } - // Merge results - return mergeResults(resultFutures, resultFactory); + return finalResult; + } + + /** + * Retrieves a rule from the registry by its class name. + * Logs a debug message if the rule is not found. + * + * @param ruleClassName the fully qualified class name of the rule + * @return the rule instance, or null if not found + */ + private IRule getRuleFromRegistry(String ruleClassName) { + IRule rule = ruleRegistry.get(ruleClassName); + if (rule == null) { + log.debug("Skipping rule not found in registry: {}, {}", ruleClassName, ruleRegistry.keySet()); + } + return rule; } /** - * Executes a single rule asynchronously and returns its result future. + * Executes a single rule synchronously and returns its result. *

    - * This method wraps the execution of an individual rule in a {@link CompletableFuture} to - * enable asynchronous processing. It handles the lifecycle of rule execution including: + * This method handles the lifecycle of rule execution including: *