Repository: Opetushallitus/jod-yksilo
Pull request #125: OPHJOD-1375: Replace URI String-type to URI-type
Author: login: nhut-do-elenium
Created: 2025-03-28T14:59:00Z | Merged: 2025-03-31T11:16:38Z

## Description

This pull request enhances URI handling to ensure consistency, type safety, and validation.

## Related JIRA ticket

https://jira.eduuni.fi/browse/OPHJOD-137


Diff:
diff --git a/src/main/java/fi/okm/jod/yksilo/config/suomifi/Attribute.java b/src/main/java/fi/okm/jod/yksilo/config/suomifi/Attribute.java
index 85f2fbda..bcceb8b2 100644
--- a/src/main/java/fi/okm/jod/yksilo/config/suomifi/Attribute.java
+++ b/src/main/java/fi/okm/jod/yksilo/config/suomifi/Attribute.java
@@ -9,6 +9,7 @@
 
 package fi.okm.jod.yksilo.config.suomifi;
 
+import java.net.URI;
 import lombok.Getter;
 
 // https://palveluhallinta.suomi.fi/fi/tuki/artikkelit/590ad07b14bbb10001966f50
@@ -46,6 +47,6 @@ public enum Attribute {
   private final String uri;
 
   Attribute(String uri) {
-    this.uri = uri;
+    this.uri = URI.create(uri).toString();
   }
 }
diff --git a/src/main/java/fi/okm/jod/yksilo/dto/TyomahdollisuusFullDto.java b/src/main/java/fi/okm/jod/yksilo/dto/TyomahdollisuusFullDto.java
index 18e7d850..6bfd36e6 100644
--- a/src/main/java/fi/okm/jod/yksilo/dto/TyomahdollisuusFullDto.java
+++ b/src/main/java/fi/okm/jod/yksilo/dto/TyomahdollisuusFullDto.java
@@ -14,6 +14,7 @@
 import fi.okm.jod.yksilo.domain.TyomahdollisuusJakaumaTyyppi;
 import io.swagger.v3.oas.annotations.media.Schema;
 import jakarta.validation.constraints.NotNull;
+import java.net.URI;
 import java.util.Map;
 import java.util.UUID;
 
@@ -24,7 +25,7 @@ public record TyomahdollisuusFullDto(
     LocalizedString kuvaus,
     LocalizedString tehtavat,
     LocalizedString yleisetVaatimukset,
-    String ammattiryhma,
+    URI ammattiryhma,
     TyomahdollisuusAineisto aineisto,
     @Schema(
             propertyNames = TyomahdollisuusJakaumaTyyppi.class,
diff --git a/src/main/java/fi/okm/jod/yksilo/entity/Ammatti.java b/src/main/java/fi/okm/jod/yksilo/entity/Ammatti.java
index c1f76e86..e91c8c1e 100644
--- a/src/main/java/fi/okm/jod/yksilo/entity/Ammatti.java
+++ b/src/main/java/fi/okm/jod/yksilo/entity/Ammatti.java
@@ -20,6 +20,7 @@
 import jakarta.persistence.GenerationType;
 import jakarta.persistence.Id;
 import jakarta.persistence.MapKeyEnumerated;
+import java.net.URI;
 import java.util.Map;
 import lombok.Getter;
 import org.hibernate.annotations.BatchSize;
@@ -34,7 +35,7 @@ public class Ammatti {
   private Long id;
 
   @Column(unique = true, nullable = false)
-  private String uri;
+  private URI uri;
 
   @Column(nullable = false)
   private String koodi;
diff --git a/src/main/java/fi/okm/jod/yksilo/entity/Osaaminen.java b/src/main/java/fi/okm/jod/yksilo/entity/Osaaminen.java
index 84a0680f..73d89747 100644
--- a/src/main/java/fi/okm/jod/yksilo/entity/Osaaminen.java
+++ b/src/main/java/fi/okm/jod/yksilo/entity/Osaaminen.java
@@ -20,6 +20,7 @@
 import jakarta.persistence.GenerationType;
 import jakarta.persistence.Id;
 import jakarta.persistence.MapKeyEnumerated;
+import java.net.URI;
 import java.util.Map;
 import lombok.Getter;
 import org.hibernate.annotations.BatchSize;
@@ -34,7 +35,7 @@ public class Osaaminen {
   private Long id;
 
   @Column(unique = true, nullable = false)
-  private String uri;
+  private URI uri;
 
   @ElementCollection
   @MapKeyEnumerated(EnumType.STRING)
diff --git a/src/main/java/fi/okm/jod/yksilo/entity/Paamaara.java b/src/main/java/fi/okm/jod/yksilo/entity/Paamaara.java
index c4dfe35c..05d1c4ff 100644
--- a/src/main/java/fi/okm/jod/yksilo/entity/Paamaara.java
+++ b/src/main/java/fi/okm/jod/yksilo/entity/Paamaara.java
@@ -37,6 +37,7 @@
 import jakarta.persistence.OneToMany;
 import jakarta.persistence.Table;
 import jakarta.validation.constraints.NotNull;
+import java.net.URI;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.EnumMap;
@@ -136,12 +137,13 @@ public UUID getMahdollisuusId() {
     return tyomahdollisuus != null ? tyomahdollisuus.getId() : koulutusmahdollisuus.getId();
   }
 
-  public Set<String> getOsaamiset() {
+  public Set<URI> getOsaamiset() {
     if (getMahdollisuusTyyppi() == MahdollisuusTyyppi.TYOMAHDOLLISUUS) {
       var jakauma = tyomahdollisuus.getJakaumat().get(TyomahdollisuusJakaumaTyyppi.OSAAMINEN);
       if (jakauma != null && jakauma.getArvot() != null) {
         return jakauma.getArvot().stream()
             .map(Jakauma.Arvo::arvo)
+            .map(URI::create)
             .collect(Collectors.toUnmodifiableSet());
       }
     } else {
@@ -154,6 +156,7 @@ public Set<String> getOsaamiset() {
             .getArvot()
             .stream()
             .map(Jakauma.Arvo::arvo)
+            .map(URI::create)
             .collect(Collectors.toUnmodifiableSet());
       }
     }
diff --git a/src/main/java/fi/okm/jod/yksilo/entity/UriConverter.java b/src/main/java/fi/okm/jod/yksilo/entity/UriConverter.java
new file mode 100644
index 00000000..6a76c56d
--- /dev/null
+++ b/src/main/java/fi/okm/jod/yksilo/entity/UriConverter.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2025 The Finnish Ministry of Education and Culture, The Finnish
+ * The Ministry of Economic Affairs and Employment, The Finnish National Agency of
+ * Education (Opetushallitus) and The Finnish Development and Administration centre
+ * for ELY Centres and TE Offices (KEHA).
+ *
+ * Licensed under the EUPL-1.2-or-later.
+ */
+
+package fi.okm.jod.yksilo.entity;
+
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+import java.net.URI;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Converter(autoApply = true)
+public class UriConverter implements AttributeConverter<URI, String> {
+  @Override
+  public String convertToDatabaseColumn(URI attribute) {
+    // Serialization: URI object to database string.
+    if (attribute == null) {
+      return null;
+    }
+    return attribute.toString();
+  }
+
+  @Override
+  public URI convertToEntityAttribute(String dbData) {
+    // Deserialization: database string to URI object.
+    if (dbData == null) {
+      return null;
+    }
+    return URI.create(dbData);
+  }
+}
diff --git a/src/main/java/fi/okm/jod/yksilo/entity/tyomahdollisuus/Tyomahdollisuus.java b/src/main/java/fi/okm/jod/yksilo/entity/tyomahdollisuus/Tyomahdollisuus.java
index 823fa6dc..e1bc74c9 100644
--- a/src/main/java/fi/okm/jod/yksilo/entity/tyomahdollisuus/Tyomahdollisuus.java
+++ b/src/main/java/fi/okm/jod/yksilo/entity/tyomahdollisuus/Tyomahdollisuus.java
@@ -24,6 +24,7 @@
 import jakarta.persistence.MapKeyColumn;
 import jakarta.persistence.MapKeyEnumerated;
 import jakarta.persistence.OneToMany;
+import java.net.URI;
 import java.util.Map;
 import java.util.UUID;
 import lombok.Getter;
@@ -47,7 +48,7 @@ public class Tyomahdollisuus {
   @MapKeyColumn(name = "tyyppi")
   private Map<TyomahdollisuusJakaumaTyyppi, TyomahdollisuusJakauma> jakaumat;
 
-  @Column private String ammattiryhma;
+  @Column private URI ammattiryhma;
 
   @Enumerated(EnumType.STRING)
   @Column
diff --git a/src/main/java/fi/okm/jod/yksilo/repository/AmmattiRepository.java b/src/main/java/fi/okm/jod/yksilo/repository/AmmattiRepository.java
index 98fe5be3..d7492ffb 100644
--- a/src/main/java/fi/okm/jod/yksilo/repository/AmmattiRepository.java
+++ b/src/main/java/fi/okm/jod/yksilo/repository/AmmattiRepository.java
@@ -37,7 +37,7 @@ public interface AmmattiRepository extends Repository<Ammatti, Long> {
 
   Stream<Ammatti> findAll(Sort sort);
 
-  List<Ammatti> findByUriIn(Collection<String> uri);
+  List<Ammatti> findByUriIn(Collection<URI> uri);
 
   @Transactional(readOnly = true)
   default Versioned<Map<URI, AmmattiDto>> refreshAll(
@@ -46,10 +46,7 @@ default Versioned<Map<URI, AmmattiDto>> refreshAll(
     if (previous == null || previous.version() != version) {
       final var map =
           findAll(Sort.by("koodi", "id"))
-              .map(
-                  it ->
-                      new AmmattiDto(
-                          URI.create(it.getUri()), it.getKoodi(), it.getNimi(), it.getKuvaus()))
+              .map(it -> new AmmattiDto(it.getUri(), it.getKoodi(), it.getNimi(), it.getKuvaus()))
               .collect(
                   Collectors.toMap(
                       AmmattiDto::uri,
diff --git a/src/main/java/fi/okm/jod/yksilo/repository/OsaaminenRepository.java b/src/main/java/fi/okm/jod/yksilo/repository/OsaaminenRepository.java
index 88775599..fbfa41dd 100644
--- a/src/main/java/fi/okm/jod/yksilo/repository/OsaaminenRepository.java
+++ b/src/main/java/fi/okm/jod/yksilo/repository/OsaaminenRepository.java
@@ -37,13 +37,13 @@ public interface OsaaminenRepository extends Repository<Osaaminen, Long> {
 
   Page<Osaaminen> findAll(Pageable page);
 
-  List<Osaaminen> findByUriIn(Collection<String> uri);
+  List<Osaaminen> findByUriIn(Collection<URI> uri);
 
   @Transactional(readOnly = true)
   default SequencedMap<URI, OsaaminenDto> loadAll() {
     final var map =
         findAll(Sort.by("id"))
-            .map(it -> new OsaaminenDto(URI.create(it.getUri()), it.getNimi(), it.getKuvaus()))
+            .map(it -> new OsaaminenDto(it.getUri(), it.getNimi(), it.getKuvaus()))
             .collect(
                 Collectors.toMap(
                     OsaaminenDto::uri,
@@ -63,7 +63,7 @@ default Versioned<Map<URI, OsaaminenDto>> refreshAll(
     if (previous == null || previous.version() != version) {
       final var map =
           findAll(Sort.by(Osaaminen_.URI, Osaaminen_.ID))
-              .map(it -> new OsaaminenDto(URI.create(it.getUri()), it.getNimi(), it.getKuvaus()))
+              .map(it -> new OsaaminenDto(it.getUri(), it.getNimi(), it.getKuvaus()))
               .collect(
                   Collectors.toMap(
                       OsaaminenDto::uri,
diff --git a/src/main/java/fi/okm/jod/yksilo/service/profiili/KiinnostusService.java b/src/main/java/fi/okm/jod/yksilo/service/profiili/KiinnostusService.java
index 539077fd..17da31be 100644
--- a/src/main/java/fi/okm/jod/yksilo/service/profiili/KiinnostusService.java
+++ b/src/main/java/fi/okm/jod/yksilo/service/profiili/KiinnostusService.java
@@ -43,9 +43,8 @@ public Set<URI> getOsaamiset(JodUser user) {
 
   public void updateOsaamiset(JodUser user, Set<URI> kiinnostukset) {
     var yksilo = getYksilo(user);
-    var osaamisetEntities =
-        osaamiset.findByUriIn(kiinnostukset.stream().map(URI::toString).toList());
-    var ammatitEntities = ammatit.findByUriIn(kiinnostukset.stream().map(URI::toString).toList());
+    var osaamisetEntities = osaamiset.findByUriIn(kiinnostukset);
+    var ammatitEntities = ammatit.findByUriIn(kiinnostukset);
     if ((osaamisetEntities.size() + ammatitEntities.size()) != kiinnostukset.size()) {
       throw new ServiceValidationException("Invalid kiinnostus");
     }
diff --git a/src/main/java/fi/okm/jod/yksilo/service/profiili/Mapper.java b/src/main/java/fi/okm/jod/yksilo/service/profiili/Mapper.java
index 473309e2..7b84acb3 100644
--- a/src/main/java/fi/okm/jod/yksilo/service/profiili/Mapper.java
+++ b/src/main/java/fi/okm/jod/yksilo/service/profiili/Mapper.java
@@ -37,7 +37,6 @@
 import fi.okm.jod.yksilo.entity.Toiminto;
 import fi.okm.jod.yksilo.entity.Tyopaikka;
 import fi.okm.jod.yksilo.entity.YksilonOsaaminen;
-import java.net.URI;
 import java.util.Collection;
 import java.util.IdentityHashMap;
 import java.util.List;
@@ -59,7 +58,7 @@ public static ToimenkuvaDto mapToimenkuva(Toimenkuva entity) {
             entity.getAlkuPvm(),
             entity.getLoppuPvm(),
             entity.getOsaamiset().stream()
-                .map(o -> URI.create(o.getOsaaminen().getUri()))
+                .map(o -> o.getOsaaminen().getUri())
                 .collect(Collectors.toUnmodifiableSet()));
   }
 
@@ -84,7 +83,7 @@ public static KoulutusDto mapKoulutus(Koulutus entity) {
             entity.getAlkuPvm(),
             entity.getLoppuPvm(),
             entity.getOsaamiset().stream()
-                .map(o -> URI.create(o.getOsaaminen().getUri()))
+                .map(o -> o.getOsaaminen().getUri())
                 .collect(Collectors.toUnmodifiableSet()));
   }
 
@@ -107,7 +106,7 @@ public static PatevyysDto mapPatevyys(Patevyys entity) {
             entity.getAlkuPvm(),
             entity.getLoppuPvm(),
             entity.getOsaamiset().stream()
-                .map(o -> URI.create(o.getOsaaminen().getUri()))
+                .map(o -> o.getOsaaminen().getUri())
                 .collect(Collectors.toUnmodifiableSet()));
   }
 
@@ -122,7 +121,7 @@ public static KoulutusKokonaisuusDto mapKoulutusKokonaisuus(KoulutusKokonaisuus
 
   public static OsaaminenDto mapOsaaminen(Osaaminen entity) {
     requireNonNull(entity);
-    return new OsaaminenDto(URI.create(entity.getUri()), entity.getNimi(), entity.getKuvaus());
+    return new OsaaminenDto(entity.getUri(), entity.getNimi(), entity.getKuvaus());
   }
 
   public static YksilonOsaaminenDto mapYksilonOsaaminen(YksilonOsaaminen entity) {
@@ -187,10 +186,10 @@ public static PolunSuunnitelmaDto mapPolunSuunnitelma(PolunSuunnitelma entity) {
                 entity.getPaamaara().getId(), entity.getPaamaara().getTavoite()),
             entity.getVaiheet().stream().map(Mapper::mapPolunVaihe).collect(Collectors.toSet()),
             entity.getOsaamiset().stream()
-                .map(o -> URI.create(o.getUri()))
+                .map(Osaaminen::getUri)
                 .collect(Collectors.toUnmodifiableSet()),
             entity.getIgnoredOsaamiset().stream()
-                .map(o -> URI.create(o.getUri()))
+                .map(Osaaminen::getUri)
                 .collect(Collectors.toUnmodifiableSet()));
   }
 
@@ -206,7 +205,7 @@ public static PolunVaiheDto mapPolunVaihe(PolunVaihe entity) {
             entity.getAlkuPvm(),
             entity.getLoppuPvm(),
             entity.getOsaamiset().stream()
-                .map(o -> URI.create(o.getUri()))
+                .map(Osaaminen::getUri)
                 .collect(Collectors.toUnmodifiableSet()),
             entity.isValmis());
   }
diff --git a/src/main/java/fi/okm/jod/yksilo/service/profiili/PolunSuunnitelmaService.java b/src/main/java/fi/okm/jod/yksilo/service/profiili/PolunSuunnitelmaService.java
index baf4af01..c860b150 100644
--- a/src/main/java/fi/okm/jod/yksilo/service/profiili/PolunSuunnitelmaService.java
+++ b/src/main/java/fi/okm/jod/yksilo/service/profiili/PolunSuunnitelmaService.java
@@ -118,9 +118,8 @@ private void delete(PolunSuunnitelma entity) {
 
   private List<Osaaminen> getOsaamiset(
       PolunSuunnitelma suunnitelma, PolunSuunnitelmaUpdateDto dto) {
-    var ids =
-        dto.osaamiset().stream().map(Object::toString).collect(Collectors.toUnmodifiableSet());
-    var osaamiset = osaamisetRepository.findByUriIn(ids);
+    var ids = dto.osaamiset();
+    var osaamiset = osaamisetRepository.findByUriIn(dto.osaamiset());
     var vaiheOsaamiset =
         suunnitelma.getVaiheet().stream()
             .flatMap(v -> v.getOsaamiset().stream())
@@ -141,10 +140,7 @@ private List<Osaaminen> getOsaamiset(
 
   private List<Osaaminen> getIgnoredOsaamiset(
       PolunSuunnitelma suunnitelma, PolunSuunnitelmaUpdateDto dto) {
-    var ids =
-        dto.ignoredOsaamiset().stream()
-            .map(Object::toString)
-            .collect(Collectors.toUnmodifiableSet());
+    var ids = dto.ignoredOsaamiset();
     var osaamiset = osaamisetRepository.findByUriIn(ids);
 
     if (osaamiset.size() != ids.size()) {
diff --git a/src/main/java/fi/okm/jod/yksilo/service/profiili/PolunVaiheService.java b/src/main/java/fi/okm/jod/yksilo/service/profiili/PolunVaiheService.java
index d3d1dfb5..378df83d 100644
--- a/src/main/java/fi/okm/jod/yksilo/service/profiili/PolunVaiheService.java
+++ b/src/main/java/fi/okm/jod/yksilo/service/profiili/PolunVaiheService.java
@@ -21,7 +21,6 @@
 import fi.okm.jod.yksilo.service.ServiceValidationException;
 import fi.okm.jod.yksilo.validation.Limits;
 import java.util.List;
-import java.util.Objects;
 import java.util.UUID;
 import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
@@ -103,23 +102,18 @@ private void delete(PolunVaihe entity) {
   }
 
   private List<Osaaminen> getOsaamiset(PolunVaihe vaihe, PolunVaiheDto dto) {
-    var ids =
-        dto.osaamiset().stream().map(Object::toString).collect(Collectors.toUnmodifiableSet());
+    var ids = dto.osaamiset();
     var osaamiset = osaamisetRepository.findByUriIn(ids);
     var suunnitelma = vaihe.getPolunSuunnitelma();
     var paamaaraOsaamiset = suunnitelma.getPaamaara().getOsaamiset();
     var suunnitelmaOsaamiset =
-        suunnitelma.getOsaamiset().stream()
-            .map(Osaaminen::getUri)
-            .map(Objects::toString)
-            .collect(Collectors.toSet());
+        suunnitelma.getOsaamiset().stream().map(Osaaminen::getUri).collect(Collectors.toSet());
     var vaiheet = suunnitelma.getVaiheet();
     var vaiheetOsaamiset =
         vaiheet.stream()
             .filter(v -> v.getId() != vaihe.getId())
             .flatMap(v -> v.getOsaamiset().stream())
             .map(Osaaminen::getUri)
-            .map(Objects::toString)
             .collect(Collectors.toSet());
 
     if (osaamiset.size() != ids.size()) {
diff --git a/src/main/java/fi/okm/jod/yksilo/service/profiili/YksilonOsaaminenService.java b/src/main/java/fi/okm/jod/yksilo/service/profiili/YksilonOsaaminenService.java
index 4000500d..33ce7812 100644
--- a/src/main/java/fi/okm/jod/yksilo/service/profiili/YksilonOsaaminenService.java
+++ b/src/main/java/fi/okm/jod/yksilo/service/profiili/YksilonOsaaminenService.java
@@ -23,11 +23,11 @@
 import fi.okm.jod.yksilo.service.ServiceValidationException;
 import java.net.URI;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 import java.util.UUID;
-import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.data.domain.Sort;
@@ -77,7 +77,7 @@ public void delete(JodUser user, Set<UUID> ids) {
   }
 
   List<YksilonOsaaminen> add(OsaamisenLahde lahde, Set<URI> ids) {
-    var osaamiset = osaamisetRepository.findByUriIn(ids.stream().map(Object::toString).toList());
+    var osaamiset = osaamisetRepository.findByUriIn(ids);
     if (osaamiset.size() != ids.size()) {
       throw new ServiceValidationException("Unknown osaaminen");
     }
@@ -87,25 +87,25 @@ List<YksilonOsaaminen> add(OsaamisenLahde lahde, Set<URI> ids) {
     return entities;
   }
 
-  void update(OsaamisenLahde lahde, Set<URI> ids) {
+  void update(OsaamisenLahde lahde, Set<URI> originalIds) {
+    var ids = new HashSet<>(originalIds);
 
     var deleted = new ArrayList<YksilonOsaaminen>();
-    var refs = ids.stream().map(Object::toString).collect(Collectors.toSet());
 
     for (var i = lahde.getOsaamiset().iterator(); i.hasNext(); ) {
       var o = i.next();
-      final String uri = o.getOsaaminen().getUri();
-      if (!refs.contains(uri)) {
+      final URI uri = o.getOsaaminen().getUri();
+      if (!ids.contains(uri)) {
         i.remove();
         deleted.add(o);
       } else {
-        refs.remove(uri);
+        ids.remove(uri);
       }
     }
     repository.deleteAllInBatch(deleted);
 
-    var osaamiset = osaamisetRepository.findByUriIn(refs);
-    if (osaamiset.size() != refs.size()) {
+    var osaamiset = osaamisetRepository.findByUriIn(ids);
+    if (osaamiset.size() != ids.size()) {
       throw new ServiceValidationException("Unknown osaaminen");
     }
 


Reviews:
user: login: jhyoty
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T15:14:00Z
user: login: nhut-do-elenium
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T08:10:02Z
user: login: jhyoty
body: Approved with one suggestion.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T09:09:26Z
user: login: nhut-do-elenium
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T10:34:58Z

Review comments:
diff_hunk: @@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2025 The Finnish Ministry of Education and Culture, The Finnish
+ * The Ministry of Economic Affairs and Employment, The Finnish National Agency of
+ * Education (Opetushallitus) and The Finnish Development and Administration centre
+ * for ELY Centres and TE Offices (KEHA).
+ *
+ * Licensed under the EUPL-1.2-or-later.
+ */
+
+package fi.okm.jod.yksilo.entity;
+
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+import java.net.URI;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Converter(autoApply = true)
+public class UriConverter implements AttributeConverter<URI, String> {
+  @Override
+  public String convertToDatabaseColumn(URI attribute) {
+    // Serialization: URI object to database string.
+    if (attribute == null) {
+      return null;
+    }
+    if (attribute.getScheme() == null) {
+      throw new IllegalArgumentException("URI scheme is null from: " + attribute);
+    }
path: src/main/java/fi/okm/jod/yksilo/entity/UriConverter.java
user: login: jhyoty
body: UriConverter should accept any URI, no need to do restrictions here (responsibility of a validator, not a converter)
created_at: 2025-03-28T15:04:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2025 The Finnish Ministry of Education and Culture, The Finnish
+ * The Ministry of Economic Affairs and Employment, The Finnish National Agency of
+ * Education (Opetushallitus) and The Finnish Development and Administration centre
+ * for ELY Centres and TE Offices (KEHA).
+ *
+ * Licensed under the EUPL-1.2-or-later.
+ */
+
+package fi.okm.jod.yksilo.entity;
+
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+import java.net.URI;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Converter(autoApply = true)
+public class UriConverter implements AttributeConverter<URI, String> {
+  @Override
+  public String convertToDatabaseColumn(URI attribute) {
+    // Serialization: URI object to database string.
+    if (attribute == null) {
+      return null;
+    }
+    if (attribute.getScheme() == null) {
+      throw new IllegalArgumentException("URI scheme is null from: " + attribute);
+    }
+    return attribute.toString();
+  }
+
+  @Override
+  public URI convertToEntityAttribute(String dbData) {
+    // Deserialization: database string to URI object.
+    if (dbData == null) {
+      return null;
+    }
+    try {
+      return URI.create(dbData);
+    } catch (IllegalArgumentException e) {
+      log.error("Failed to convert database value to URI: {}", dbData, e);
+      throw e;
+    }
path: src/main/java/fi/okm/jod/yksilo/entity/UriConverter.java
user: login: jhyoty
body: Seems unnecessary to add logging here, the exception already conveys the error (and it will be eventually logged).
created_at: 2025-03-28T15:04:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,6 +47,10 @@ public enum Attribute {
   private final String uri;
 
   Attribute(String uri) {
+    var parsedUri = URI.create(uri);
+    if (parsedUri.getScheme() == null) {
+      throw new IllegalArgumentException("URI must have a scheme: " + uri);
+    }
path: src/main/java/fi/okm/jod/yksilo/config/suomifi/Attribute.java
user: login: jhyoty
body: Why this additional verification? 
created_at: 2025-03-28T15:05:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,7 +36,8 @@ public class Ammatti {
   private Long id;
 
   @Column(unique = true, nullable = false)
-  private String uri;
+  @Convert(converter = UriConverter.class)
path: src/main/java/fi/okm/jod/yksilo/entity/Ammatti.java
user: login: jhyoty
body: Redundant (autoApply is true)
created_at: 2025-03-28T15:06:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,6 +47,10 @@ public enum Attribute {
   private final String uri;
 
   Attribute(String uri) {
+    var parsedUri = URI.create(uri);
+    if (parsedUri.getScheme() == null) {
+      throw new IllegalArgumentException("URI must have a scheme: " + uri);
+    }
path: src/main/java/fi/okm/jod/yksilo/config/suomifi/Attribute.java
user: login: nhut-do-elenium
body: I though it would be a good addon since all have schema.
created_at: 2025-03-31T08:10:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,9 +44,9 @@ public enum Attribute {
   FAMILY_NAME("http://eidas.europa.eu/attributes/naturalperson/CurrentFamilyName"),
   DATE_OF_BIRTH("http://eidas.europa.eu/attributes/naturalperson/DateOfBirth");
 
-  private final String uri;
+  private final URI uri;
 
   Attribute(String uri) {
-    this.uri = uri;
+    this.uri = URI.create(uri);
path: src/main/java/fi/okm/jod/yksilo/config/suomifi/Attribute.java
user: login: jhyoty
body: Because the Attribute class is only used in a specific context where the collaborators operate on strings, I think it would be cleaner (at the call sites) to store the uri as a string, e.g. this.uri = URI.create(uri).toString();
created_at: 2025-03-31T09:08:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,9 +44,9 @@ public enum Attribute {
   FAMILY_NAME("http://eidas.europa.eu/attributes/naturalperson/CurrentFamilyName"),
   DATE_OF_BIRTH("http://eidas.europa.eu/attributes/naturalperson/DateOfBirth");
 
-  private final String uri;
+  private final URI uri;
 
   Attribute(String uri) {
-    this.uri = uri;
+    this.uri = URI.create(uri);
path: src/main/java/fi/okm/jod/yksilo/config/suomifi/Attribute.java
user: login: nhut-do-elenium
body: Ok
created_at: 2025-03-31T10:34:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-31T11:09:04Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Opetushallitus_jod-yksilo&pullRequest=125) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Opetushallitus_jod-yksilo&pullRequest=125&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Opetushallitus_jod-yksilo&pullRequest=125&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Opetushallitus_jod-yksilo&pullRequest=125&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [88.4% Coverage on New Code](https://sonarcloud.io/component_measures?id=Opetushallitus_jod-yksilo&pullRequest=125&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Opetushallitus_jod-yksilo&pullRequest=125&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Opetushallitus_jod-yksilo&pullRequest=125)


author_association: NONE