Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Clarify error message when creating an ontology with an invalid name #3476

Merged
merged 10 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -514,20 +515,19 @@ 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(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))
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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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

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, isBuiltIn: Boolean) 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(
"ontology",
"rdf",
"rdfs",
"owl",
"xsd",
"schema",
"shared",
"simple",
)

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: 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) =>
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, OntologyConstants.BuiltInOntologyLabels.contains(value)))
.toEither
.left
.map(_.mkString(s"$typ ", ", ", "."))

def from(str: String): Either[String, OntologyName] =
fromValidations(
"OntologyName",
List(
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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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("OntologyName")(
test("should create a valid OntologyName") {
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))
}
},
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.isBuiltIn, it.value)) == Right((true, name)))
}
},
test("must not contain reserved words") {
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: rdf, shared, ontology, owl, simple, xsd, schema, rdfs.",
),
)
}
},
test("must not create with invalid value") {
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) == 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."),
)
}
},
)
}
Loading