From ec7f525d0eabbef9d3ed3ee22c7b741da133b440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 13:39:08 +0100 Subject: [PATCH 01/10] feat: Clarify error message when creating an ontology with an invalid name --- .../webapi/messages/StringFormatter.scala | 9 +-- .../webapi/messages/ValuesValidator.scala | 38 ------------ .../responders/v2/OntologyResponderV2.scala | 19 +++--- .../ontology/domain/model/OntologyName.scala | 60 +++++++++++++++++++ 4 files changed, 76 insertions(+), 50 deletions(-) create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala diff --git a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala index 574d802bff..8cee08aceb 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala @@ -29,6 +29,7 @@ import org.knora.webapi.messages.store.triplestoremessages.StringLiteralSequence import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2 import org.knora.webapi.messages.v2.responder.KnoraContentV2 import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.ontology.domain.model.OntologyName import org.knora.webapi.slice.resourceinfo.domain.InternalIri import org.knora.webapi.util.Base64UrlCheckDigit import org.knora.webapi.util.JavaUtil @@ -892,7 +893,7 @@ class StringFormatter private ( val ontologyName = ontologyPath.last val hasBuiltInOntologyName = isBuiltInOntologyName(ontologyName) - if (!hasBuiltInOntologyName && ValuesValidator.validateProjectSpecificOntologyName(ontologyName).isEmpty) { + if (!hasBuiltInOntologyName && OntologyName.from(ontologyName).isLeft) { errorFun } @@ -1462,11 +1463,11 @@ class StringFormatter private ( * @return the ontology IRI. */ def makeProjectSpecificInternalOntologyIri( - internalOntologyName: String, + internalOntologyName: OntologyName, isShared: Boolean, - projectCode: String, + projectCode: Shortcode, ): SmartIri = - toSmartIri(makeInternalOntologyIriStr(internalOntologyName, isShared, Some(projectCode))) + toSmartIri(makeInternalOntologyIriStr(internalOntologyName.value, isShared, Some(projectCode.value))) /** * Converts an internal ontology name to an external ontology name. This only affects `knora-base`, whose diff --git a/webapi/src/main/scala/org/knora/webapi/messages/ValuesValidator.scala b/webapi/src/main/scala/org/knora/webapi/messages/ValuesValidator.scala index 3e16c2c0fd..d69c396c1b 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/ValuesValidator.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/ValuesValidator.scala @@ -147,42 +147,4 @@ object ValuesValidator { */ def optionStringToBoolean(maybe: Option[String], fallback: Boolean): Boolean = optionStringToBoolean(maybe).getOrElse(fallback) - - /** - * Checks that a name is valid as a project-specific ontology name. - * - * @param ontologyName the ontology name to be checked. - */ - def validateProjectSpecificOntologyName(ontologyName: String): Option[String] = { - // TODO: below separators still exists in SF - think about how all consts should be distributed - - val nCNamePattern = """[\p{L}_][\p{L}0-9_.-]*""" - val nCNameRegex = ("^" + nCNamePattern + "$").r - val isNCName = nCNameRegex.matches(ontologyName) - - val base64UrlPattern = "[A-Za-z0-9_-]+" - val base64UrlPatternRegex = ("^" + base64UrlPattern + "$").r - val isUrlSafe = base64UrlPatternRegex.matches(ontologyName) - - val apiVersionNumberRegex = "^v[0-9]+.*$".r - val isNotAVersionNumber = !apiVersionNumberRegex.matches(ontologyName.toLowerCase()) - - val isNotABuiltInOntology = !OntologyConstants.BuiltInOntologyLabels.contains(ontologyName) - - val versionSegmentWords = Set("simple", "v2") - val reservedIriWords = - Set("knora", "ontology", "rdf", "rdfs", "owl", "xsd", "schema", "shared") ++ versionSegmentWords - val isNotReservedIriWord = - reservedIriWords.forall(reserverdWord => !ontologyName.toLowerCase().contains(reserverdWord)) - - if ( - isNCName && - isUrlSafe && - isNotAVersionNumber && - isNotABuiltInOntology && - isNotReservedIriWord - ) Some(ontologyName) - else None - } - } diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala index beeee77cd6..898a0a068d 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala @@ -45,6 +45,7 @@ import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.domain.model.User import org.knora.webapi.slice.admin.domain.service.KnoraProjectService import org.knora.webapi.slice.ontology.domain.model.Cardinality +import org.knora.webapi.slice.ontology.domain.model.OntologyName import org.knora.webapi.slice.ontology.domain.service.CardinalityService import org.knora.webapi.slice.ontology.domain.service.ChangeCardinalityCheckResult.CanSetCardinalityCheckResult import org.knora.webapi.slice.ontology.domain.service.OntologyRepo @@ -514,20 +515,22 @@ final case class OntologyResponderV2( // Check that the ontology name is valid. validOntologyName <- ZIO - .fromOption(ValuesValidator.validateProjectSpecificOntologyName(createOntologyRequest.ontologyName)) - .orElseFail( - BadRequestException(s"Invalid project-specific ontology name: ${createOntologyRequest.ontologyName}"), + .fromEither(OntologyName.from(createOntologyRequest.ontologyName)) + .mapError(err => + BadRequestException( + s"Invalid project-specific ontology name: ${createOntologyRequest.ontologyName}, reason: ${err}", + ), ) // Make the internal ontology IRI. projectId <- ZIO.fromEither(ProjectIri.from(projectIri.toString)).mapError(e => BadRequestException(e)) project <- knoraProjectService.findById(projectId).someOrFail(BadRequestException(s"Project not found: $projectIri")) - internalOntologyIri = stringFormatter.makeProjectSpecificInternalOntologyIri( - validOntologyName, - createOntologyRequest.isShared, - project.shortcode.value, - ) + internalOntologyIri: SmartIri = stringFormatter.makeProjectSpecificInternalOntologyIri( + validOntologyName, + createOntologyRequest.isShared, + project.shortcode, + ) // Do the remaining pre-update checks and the update while holding a global ontology cache lock. taskResult <- IriLocker.runWithIriLock( diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala new file mode 100644 index 0000000000..0fdd7e1c9b --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala @@ -0,0 +1,60 @@ +package org.knora.webapi.slice.ontology.domain.model +import zio.prelude.Validation +import zio.prelude.ZValidation + +import scala.util.matching.Regex + +import org.knora.webapi.messages.OntologyConstants +import org.knora.webapi.slice.common.StringValueCompanion +import org.knora.webapi.slice.common.Value.StringValue + +final case class OntologyName(value: String) extends StringValue +object OntologyName extends StringValueCompanion[OntologyName] { + + private val nCNameRegex: Regex = "^[\\p{L}_][\\p{L}0-9_.-]*$".r + private val urlSafeRegex: Regex = "^[A-Za-z0-9_-]+$".r + private val apiVersionNumberRegex: Regex = "^v[0-9]+.*$".r + private val reservedWords: Set[String] = + Set( + "knora", + "ontology", + "rdf", + "rdfs", + "owl", + "xsd", + "schema", + "shared", + "simple", + ) ++ OntologyConstants.BuiltInOntologyLabels + + private def matchesRegex(regex: Regex, msg: String): String => Validation[String, String] = + (str: String) => Validation.fromPredicateWith(s"must match regex: ${regex.toString()}: $msg")(str)(regex.matches) + + private def notContainsReservedWord(reservedWords: Set[String]): String => Validation[String, String] = + (str: String) => + Validation.fromPredicateWith(s"must not contain reserved words: $reservedWords")(str)(value => + reservedWords.forall(word => !value.toLowerCase().contains(word.toLowerCase)), + ) + + private def fromValidations( + typ: String, + validations: List[String => Validation[String, String]], + ): String => Either[String, OntologyName] = value => + ZValidation + .validateAll(validations.map(_(value))) + .as(OntologyName(value)) + .toEither + .left + .map(_.mkString(s"$typ ", ", ", ".")) + + def from(str: String): Either[String, OntologyName] = + fromValidations( + "OntologyName", + List( + matchesRegex(nCNameRegex, "must be a valid NCName"), + matchesRegex(urlSafeRegex, "must be url safe"), + matchesRegex(apiVersionNumberRegex, "must not start with 'v' followed by a number"), + notContainsReservedWord(reservedWords), + ), + )(str) +} From ab9b8d89c308b0b3a719473d5141dbf82e1484a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 13:41:52 +0100 Subject: [PATCH 02/10] fmt, copyright header --- .../ontology/domain/model/OntologyName.scala | 11 ++++++++++- .../domain/model/OntologyNameSpec.scala | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala index 0fdd7e1c9b..e13289acd8 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala @@ -1,3 +1,8 @@ +/* + * Copyright © 2021 - 2025 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + package org.knora.webapi.slice.ontology.domain.model import zio.prelude.Validation import zio.prelude.ZValidation @@ -30,6 +35,10 @@ object OntologyName extends StringValueCompanion[OntologyName] { private def matchesRegex(regex: Regex, msg: String): String => Validation[String, String] = (str: String) => Validation.fromPredicateWith(s"must match regex: ${regex.toString()}: $msg")(str)(regex.matches) + private def notMatchesRegex(regex: Regex, msg: String): String => Validation[String, String] = + (str: String) => + Validation.fromPredicateWith(s"must not match regex: ${regex.toString()}: $msg")(str)(!regex.matches(_)) + private def notContainsReservedWord(reservedWords: Set[String]): String => Validation[String, String] = (str: String) => Validation.fromPredicateWith(s"must not contain reserved words: $reservedWords")(str)(value => @@ -53,7 +62,7 @@ object OntologyName extends StringValueCompanion[OntologyName] { List( matchesRegex(nCNameRegex, "must be a valid NCName"), matchesRegex(urlSafeRegex, "must be url safe"), - matchesRegex(apiVersionNumberRegex, "must not start with 'v' followed by a number"), + notMatchesRegex(apiVersionNumberRegex, "must not start with 'v' followed by a number"), notContainsReservedWord(reservedWords), ), )(str) diff --git a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala new file mode 100644 index 0000000000..caf27ac1e5 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala @@ -0,0 +1,17 @@ +/* + * Copyright © 2021 - 2025 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.ontology.domain.model +import zio.test.* + +object OntologyNameSpec extends ZIOSpecDefault { + val spec = suite("OntologyNameSpec")(test("should") { + val validNames = List("anything") + check(Gen.fromIterable(validNames)) { name => + val result = OntologyName.from(name) + assertTrue(result.map(_.value) == Right(name)) + } + }) +} From fcd8cf8e787170239ffaf5d173e0521a05f06e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 15:21:37 +0100 Subject: [PATCH 03/10] add test --- .../domain/model/OntologyNameSpec.scala | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala index caf27ac1e5..16904e30ba 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala @@ -7,11 +7,26 @@ package org.knora.webapi.slice.ontology.domain.model import zio.test.* object OntologyNameSpec extends ZIOSpecDefault { - val spec = suite("OntologyNameSpec")(test("should") { - val validNames = List("anything") - check(Gen.fromIterable(validNames)) { name => - val result = OntologyName.from(name) - assertTrue(result.map(_.value) == Right(name)) - } - }) + val spec = suite("OntologyName")( + test("should create a valid OntologyName") { + val validNames = List("anything", "beol", "limc") + check(Gen.fromIterable(validNames)) { name => + val result = OntologyName.from(name) + assertTrue(result.map(_.value) == Right(name)) + } + }, + test("must not contain reserved words") { + val reservedWords = List("knora", "ontology", "rdf", "rdfs", "owl", "xsd", "schema", "shared", "simple") + check(Gen.fromIterable(reservedWords)) { reservedWord => + val result = OntologyName.from(reservedWord) + assertTrue( + result == Left( + "OntologyName must not contain reserved words: HashSet(rdf, knora, standoff, ontology, knora-admin, " + + "knora-base, owl, salsah-gui, simple, xsd, shared, knora-api, schema, rdfs).", + ), + ) + } + }, + ) + } From f14741c88e47dec4568da8aad99a5accefdb6dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 15:55:46 +0100 Subject: [PATCH 04/10] allow internal OntologyNames --- .../responders/v2/OntologyResponderV2.scala | 1 + .../ontology/domain/model/OntologyName.scala | 21 ++++++++++++------- .../domain/model/OntologyNameSpec.scala | 21 ++++++++++++++----- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala index 898a0a068d..27048bc6e8 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala @@ -521,6 +521,7 @@ final case class OntologyResponderV2( s"Invalid project-specific ontology name: ${createOntologyRequest.ontologyName}, reason: ${err}", ), ) + .filterOrFail(!_.isInternal)(BadRequestException("Internal ontologies cannot be created")) // Make the internal ontology IRI. projectId <- ZIO.fromEither(ProjectIri.from(projectIri.toString)).mapError(e => BadRequestException(e)) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala index e13289acd8..3842141d38 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala @@ -13,7 +13,7 @@ import org.knora.webapi.messages.OntologyConstants import org.knora.webapi.slice.common.StringValueCompanion import org.knora.webapi.slice.common.Value.StringValue -final case class OntologyName(value: String) extends StringValue +final case class OntologyName(value: String, isInternal: Boolean) extends StringValue object OntologyName extends StringValueCompanion[OntologyName] { private val nCNameRegex: Regex = "^[\\p{L}_][\\p{L}0-9_.-]*$".r @@ -21,7 +21,6 @@ object OntologyName extends StringValueCompanion[OntologyName] { private val apiVersionNumberRegex: Regex = "^v[0-9]+.*$".r private val reservedWords: Set[String] = Set( - "knora", "ontology", "rdf", "rdfs", @@ -30,28 +29,35 @@ object OntologyName extends StringValueCompanion[OntologyName] { "schema", "shared", "simple", - ) ++ OntologyConstants.BuiltInOntologyLabels + ) private def matchesRegex(regex: Regex, msg: String): String => Validation[String, String] = - (str: String) => Validation.fromPredicateWith(s"must match regex: ${regex.toString()}: $msg")(str)(regex.matches) + (str: String) => Validation.fromPredicateWith(s"$msg and must match regex: ${regex.toString()}")(str)(regex.matches) private def notMatchesRegex(regex: Regex, msg: String): String => Validation[String, String] = (str: String) => - Validation.fromPredicateWith(s"must not match regex: ${regex.toString()}: $msg")(str)(!regex.matches(_)) + Validation.fromPredicateWith(s"$msg and must not match regex: ${regex.toString()}: $msg")(str)(!regex.matches(_)) private def notContainsReservedWord(reservedWords: Set[String]): String => Validation[String, String] = (str: String) => - Validation.fromPredicateWith(s"must not contain reserved words: $reservedWords")(str)(value => + Validation.fromPredicateWith(s"must not contain reserved words: ${reservedWords.mkString(", ")}")(str)(value => reservedWords.forall(word => !value.toLowerCase().contains(word.toLowerCase)), ) + private def notContainKnoraIfNotInternal: String => Validation[String, String] = + (str: String) => + Validation.fromPredicateWith("must not contain 'knora' if not internal")(str)(value => + if (OntologyConstants.BuiltInOntologyLabels.contains(str)) true + else !value.toLowerCase().contains("knora"), + ) + private def fromValidations( typ: String, validations: List[String => Validation[String, String]], ): String => Either[String, OntologyName] = value => ZValidation .validateAll(validations.map(_(value))) - .as(OntologyName(value)) + .as(OntologyName(value, OntologyConstants.BuiltInOntologyLabels.contains(value))) .toEither .left .map(_.mkString(s"$typ ", ", ", ".")) @@ -64,6 +70,7 @@ object OntologyName extends StringValueCompanion[OntologyName] { matchesRegex(urlSafeRegex, "must be url safe"), notMatchesRegex(apiVersionNumberRegex, "must not start with 'v' followed by a number"), notContainsReservedWord(reservedWords), + notContainKnoraIfNotInternal, ), )(str) } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala index 16904e30ba..4b9ee3a563 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala @@ -9,24 +9,35 @@ import zio.test.* object OntologyNameSpec extends ZIOSpecDefault { val spec = suite("OntologyName")( test("should create a valid OntologyName") { - val validNames = List("anything", "beol", "limc") + val validNames = List("anything", "beol", "limc", "test", "AVeryVeryVeryVeryLongLongName") check(Gen.fromIterable(validNames)) { name => val result = OntologyName.from(name) assertTrue(result.map(_.value) == Right(name)) } }, + test("should create internal OntologyName") { + val internalNames = List("knora-api", "knora-admin", "knora-base", "salsah-gui") + check(Gen.fromIterable(internalNames)) { name => + val result = OntologyName.from(name) + assertTrue(result.map(it => (it.isInternal, it.value)) == Right((true, name))) + } + }, test("must not contain reserved words") { - val reservedWords = List("knora", "ontology", "rdf", "rdfs", "owl", "xsd", "schema", "shared", "simple") + val reservedWords = List("owl", "ontology", "rdf", "rdfs", "owl", "xsd", "schema", "shared", "simple") check(Gen.fromIterable(reservedWords)) { reservedWord => val result = OntologyName.from(reservedWord) assertTrue( result == Left( - "OntologyName must not contain reserved words: HashSet(rdf, knora, standoff, ontology, knora-admin, " + - "knora-base, owl, salsah-gui, simple, xsd, shared, knora-api, schema, rdfs).", + "OntologyName must not contain reserved words: rdf, shared, ontology, owl, simple, xsd, schema, rdfs.", ), ) } }, + test("must not create with invalid value") { + val invalidNames = List("1", "1abc", "abc.1", "abc/1", "abc 1", "abc@1", "abc#1", "abc$1", "abc%1") + check(Gen.fromIterable(invalidNames)) { invalidName => + assertTrue(OntologyName.from(invalidName).isLeft) + } + }, ) - } From 25a64a075bceadf67d98a88bab6fe170a5d6019b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 15:58:30 +0100 Subject: [PATCH 05/10] add test case --- .../webapi/slice/ontology/domain/model/OntologyNameSpec.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala index 4b9ee3a563..e657b669eb 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala @@ -9,7 +9,7 @@ import zio.test.* object OntologyNameSpec extends ZIOSpecDefault { val spec = suite("OntologyName")( test("should create a valid OntologyName") { - val validNames = List("anything", "beol", "limc", "test", "AVeryVeryVeryVeryLongLongName") + val validNames = List("anything", "beol", "limc", "test", "AVeryVeryVeryVeryLongLongName", "another-onto") check(Gen.fromIterable(validNames)) { name => val result = OntologyName.from(name) assertTrue(result.map(_.value) == Right(name)) @@ -34,7 +34,7 @@ object OntologyNameSpec extends ZIOSpecDefault { } }, test("must not create with invalid value") { - val invalidNames = List("1", "1abc", "abc.1", "abc/1", "abc 1", "abc@1", "abc#1", "abc$1", "abc%1") + val invalidNames = List("1", "1abc", "abc.1", "abc/1", "abc 1", "abc@1", "abc#1", "abc$1", "abc%1", "some-knora") check(Gen.fromIterable(invalidNames)) { invalidName => assertTrue(OntologyName.from(invalidName).isLeft) } From 11687047b5d9668e4bdd2541fabf8e58862add57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 16:19:47 +0100 Subject: [PATCH 06/10] make error message human readable --- .../ontology/domain/model/OntologyName.scala | 45 +++++++++++++++---- .../domain/model/OntologyNameSpec.scala | 18 +++++++- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala index 3842141d38..79ef1f97fc 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala @@ -31,12 +31,23 @@ object OntologyName extends StringValueCompanion[OntologyName] { "simple", ) - private def matchesRegex(regex: Regex, msg: String): String => Validation[String, String] = - (str: String) => Validation.fromPredicateWith(s"$msg and must match regex: ${regex.toString()}")(str)(regex.matches) + private def matchesRegexes(regex: List[Regex], msg: Option[String] = None): String => Validation[String, String] = + (str: String) => { + val msgStr = msg.getOrElse(s"must match regexes: ${regex.mkString("'", "', '", "'")}") + Validation.fromPredicateWith(msgStr)(str)(value => regex.forall(_.matches(value))) + } - private def notMatchesRegex(regex: Regex, msg: String): String => Validation[String, String] = - (str: String) => - Validation.fromPredicateWith(s"$msg and must not match regex: ${regex.toString()}: $msg")(str)(!regex.matches(_)) + private def matchesRegex(regex: Regex, msg: Option[String] = None): String => Validation[String, String] = + (str: String) => { + val msgStr = msg.getOrElse(s"must match regex: ${regex.toString()}") + Validation.fromPredicateWith(msgStr)(str)(regex.matches) + } + + private def notMatchesRegex(regex: Regex, msg: Option[String]): String => Validation[String, String] = + (str: String) => { + val msgStr = msg.getOrElse(s"must not match regex: ${regex.toString()}") + Validation.fromPredicateWith(msgStr)(str)(!regex.matches(_)) + } private def notContainsReservedWord(reservedWords: Set[String]): String => Validation[String, String] = (str: String) => @@ -66,11 +77,29 @@ object OntologyName extends StringValueCompanion[OntologyName] { fromValidations( "OntologyName", List( - matchesRegex(nCNameRegex, "must be a valid NCName"), - matchesRegex(urlSafeRegex, "must be url safe"), - notMatchesRegex(apiVersionNumberRegex, "must not start with 'v' followed by a number"), + matchesRegexes( + List(nCNameRegex, urlSafeRegex), + Some( + "starts with a letter or an underscore and is followed by one or more alphanumeric characters, underscores, or hyphens, and does not contain any other characters", + ), + ), + notMatchesRegex(apiVersionNumberRegex, Some("must not start with 'v' followed by a number")), notContainsReservedWord(reservedWords), notContainKnoraIfNotInternal, ), )(str) + + private def combine( + v1: String => Validation[String, String], + v2: String => Validation[String, String], + msg: String, + ): String => Validation[String, String] = + (str: String) => { + val v1applied: Validation[String, String] = v1(str) + val v2applied: Validation[String, String] = v2(str) + Validation + .validateAll(List(v1applied, v2applied)) + .as(str) + .mapError(_ => msg) + } } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala index e657b669eb..e0456d8a74 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala @@ -34,9 +34,23 @@ object OntologyNameSpec extends ZIOSpecDefault { } }, test("must not create with invalid value") { - val invalidNames = List("1", "1abc", "abc.1", "abc/1", "abc 1", "abc@1", "abc#1", "abc$1", "abc%1", "some-knora") + val invalidNames = + List("1", "no space", "1abc", "abc.1", "abc/1", "abc 1", "abc@1", "abc#1", "abc$1", "abc%1") check(Gen.fromIterable(invalidNames)) { invalidName => - assertTrue(OntologyName.from(invalidName).isLeft) + assertTrue( + OntologyName.from(invalidName) == Left( + "OntologyName starts with a letter or an underscore and is followed by one or more alphanumeric characters, underscores, or hyphens, and does not contain any other characters.", + ), + ) + } + }, + test("must not contain 'knora' if not internal") { + val invalidNames = + List("some-knora", "knora-some", "some-knora-some", "knora") + check(Gen.fromIterable(invalidNames)) { invalidName => + assertTrue( + OntologyName.from(invalidName) == Left("OntologyName must not contain 'knora' if not internal."), + ) } }, ) From 9ac8b24a855f2c4d6cb135772e9b79ff6baac8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 16:25:27 +0100 Subject: [PATCH 07/10] rm unused --- .../ontology/domain/model/OntologyName.scala | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala index 79ef1f97fc..a5492785b3 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala @@ -37,12 +37,6 @@ object OntologyName extends StringValueCompanion[OntologyName] { Validation.fromPredicateWith(msgStr)(str)(value => regex.forall(_.matches(value))) } - private def matchesRegex(regex: Regex, msg: Option[String] = None): String => Validation[String, String] = - (str: String) => { - val msgStr = msg.getOrElse(s"must match regex: ${regex.toString()}") - Validation.fromPredicateWith(msgStr)(str)(regex.matches) - } - private def notMatchesRegex(regex: Regex, msg: Option[String]): String => Validation[String, String] = (str: String) => { val msgStr = msg.getOrElse(s"must not match regex: ${regex.toString()}") @@ -88,18 +82,4 @@ object OntologyName extends StringValueCompanion[OntologyName] { notContainKnoraIfNotInternal, ), )(str) - - private def combine( - v1: String => Validation[String, String], - v2: String => Validation[String, String], - msg: String, - ): String => Validation[String, String] = - (str: String) => { - val v1applied: Validation[String, String] = v1(str) - val v2applied: Validation[String, String] = v2(str) - Validation - .validateAll(List(v1applied, v2applied)) - .as(str) - .mapError(_ => msg) - } } From 92da591532eedb09d092185b29b9a9064db088dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 16:34:12 +0100 Subject: [PATCH 08/10] rename isBuiltIn --- .../scala/org/knora/webapi/messages/StringFormatter.scala | 2 +- .../knora/webapi/responders/v2/OntologyResponderV2.scala | 8 ++------ .../webapi/slice/ontology/domain/model/OntologyName.scala | 2 +- .../slice/ontology/domain/model/OntologyNameSpec.scala | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala index 8cee08aceb..8c141ed3c4 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala @@ -893,7 +893,7 @@ class StringFormatter private ( val ontologyName = ontologyPath.last val hasBuiltInOntologyName = isBuiltInOntologyName(ontologyName) - if (!hasBuiltInOntologyName && OntologyName.from(ontologyName).isLeft) { + if (OntologyName.from(ontologyName).map(!_.isBuiltIn).getOrElse(true)) { errorFun } diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala index 27048bc6e8..9be90445ef 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala @@ -516,12 +516,8 @@ final case class OntologyResponderV2( validOntologyName <- ZIO .fromEither(OntologyName.from(createOntologyRequest.ontologyName)) - .mapError(err => - BadRequestException( - s"Invalid project-specific ontology name: ${createOntologyRequest.ontologyName}, reason: ${err}", - ), - ) - .filterOrFail(!_.isInternal)(BadRequestException("Internal ontologies cannot be created")) + .mapError(BadRequestException.apply) + .filterOrFail(!_.isBuiltIn)(BadRequestException("A built in ontology cannot be created")) // Make the internal ontology IRI. projectId <- ZIO.fromEither(ProjectIri.from(projectIri.toString)).mapError(e => BadRequestException(e)) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala index a5492785b3..3df7122e3f 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/model/OntologyName.scala @@ -13,7 +13,7 @@ import org.knora.webapi.messages.OntologyConstants import org.knora.webapi.slice.common.StringValueCompanion import org.knora.webapi.slice.common.Value.StringValue -final case class OntologyName(value: String, isInternal: Boolean) extends StringValue +final case class OntologyName(value: String, isBuiltIn: Boolean) extends StringValue object OntologyName extends StringValueCompanion[OntologyName] { private val nCNameRegex: Regex = "^[\\p{L}_][\\p{L}0-9_.-]*$".r diff --git a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala index e0456d8a74..1eed6d0d7a 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/ontology/domain/model/OntologyNameSpec.scala @@ -19,7 +19,7 @@ object OntologyNameSpec extends ZIOSpecDefault { val internalNames = List("knora-api", "knora-admin", "knora-base", "salsah-gui") check(Gen.fromIterable(internalNames)) { name => val result = OntologyName.from(name) - assertTrue(result.map(it => (it.isInternal, it.value)) == Right((true, name))) + assertTrue(result.map(it => (it.isBuiltIn, it.value)) == Right((true, name))) } }, test("must not contain reserved words") { From e4feb736e6c04196608b4775a9de5769b73d4130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 16:39:58 +0100 Subject: [PATCH 09/10] fixup --- .../main/scala/org/knora/webapi/messages/StringFormatter.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala index 8c141ed3c4..8cee08aceb 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala @@ -893,7 +893,7 @@ class StringFormatter private ( val ontologyName = ontologyPath.last val hasBuiltInOntologyName = isBuiltInOntologyName(ontologyName) - if (OntologyName.from(ontologyName).map(!_.isBuiltIn).getOrElse(true)) { + if (!hasBuiltInOntologyName && OntologyName.from(ontologyName).isLeft) { errorFun } From f75b0b6bd0069c98863e893a158eb34485d1a1bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Mon, 27 Jan 2025 16:59:25 +0100 Subject: [PATCH 10/10] use latest java for test docs --- .github/workflows/build-and-test.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 05793f2e24..e0c18be1d2 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -92,11 +92,19 @@ jobs: test-docs-build: name: Test docs runs-on: ubuntu-latest + strategy: + matrix: + java: [ "21" ] env: IS_NOOP: ${{ github.ref_name == 'main' || startsWith(github.ref_name, 'release-') }} steps: - name: Inform on no-op run: echo "Running this as a no-op job... ${{ env.IS_NOOP}}" + - name: Run preparatory steps + if: ${{ env.IS_NOOP == 'false' }} + uses: dasch-swiss/dsp-api/.github/actions/preparation@main + with: + java-version: ${{ matrix.java }} - name: Checkout source if: ${{ env.IS_NOOP == 'false' }} uses: actions/checkout@v4