diff --git a/integration/src/test/scala/org/knora/webapi/responders/admin/GroupsResponderADMSpec.scala b/integration/src/test/scala/org/knora/webapi/responders/admin/GroupsResponderADMSpec.scala index 08c79e9439d..9104fe223d6 100644 --- a/integration/src/test/scala/org/knora/webapi/responders/admin/GroupsResponderADMSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/responders/admin/GroupsResponderADMSpec.scala @@ -5,7 +5,7 @@ package org.knora.webapi.responders.admin -import org.apache.pekko +import org.apache.pekko.actor.Status.Failure import java.util.UUID @@ -13,18 +13,16 @@ import dsp.errors.BadRequestException import dsp.errors.DuplicateValueException import dsp.errors.NotFoundException import dsp.valueobjects.Group._ -import dsp.valueobjects.Iri._ import dsp.valueobjects.V2 import org.knora.webapi._ import org.knora.webapi.messages.admin.responder.groupsmessages._ import org.knora.webapi.messages.admin.responder.usersmessages.UserInformationTypeADM import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2 import org.knora.webapi.sharedtestdata.SharedTestDataADM +import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.util.MutableTestIri -import pekko.actor.Status.Failure - /** * This spec is used to test the messages received by the [[org.knora.webapi.responders.admin.GroupsResponderADMSpec]] actor. */ @@ -107,9 +105,7 @@ class GroupsResponderADMSpec extends CoreSpec { "return a 'DuplicateValueException' if the supplied group name is not unique" in { appActor ! GroupCreateRequestADM( createRequest = GroupCreatePayloadADM( - id = Some( - GroupIri.make(imagesReviewerGroup.id).fold(e => throw e.head, v => v) - ), + id = Some(GroupIri.unsafeFrom(imagesReviewerGroup.id)), name = GroupName.make("NewGroup").fold(e => throw e.head, v => v), descriptions = GroupDescriptions .make(Seq(V2.StringLiteralV2(value = "NewGroupDescription", language = Some("en")))) diff --git a/webapi/src/main/scala/dsp/valueobjects/Iri.scala b/webapi/src/main/scala/dsp/valueobjects/Iri.scala index 00a9bdccf7f..bf859c3eed3 100644 --- a/webapi/src/main/scala/dsp/valueobjects/Iri.scala +++ b/webapi/src/main/scala/dsp/valueobjects/Iri.scala @@ -81,14 +81,6 @@ object Iri { def isUserIri(iri: IRI): Boolean = isIri(iri) && iri.startsWith("http://rdfh.ch/users/") - /** - * Returns `true` if an IRI string looks like a Knora group IRI. - * - * @param iri the IRI to be checked. - */ - def isGroupIri(iri: IRI): Boolean = - isIri(iri) && iri.startsWith("http://rdfh.ch/groups/") - /** * Returns `true` if an IRI string looks like a Knora project IRI * @@ -187,29 +179,6 @@ object Iri { /** * GroupIri value object. */ - sealed abstract case class GroupIri private (value: String) extends Iri - object GroupIri { self => - def make(value: String): Validation[Throwable, GroupIri] = - if (value.isEmpty) Validation.fail(BadRequestException(IriErrorMessages.GroupIriMissing)) - else { - val isUuid: Boolean = UuidUtil.hasValidLength(value.split("/").last) - - if (!isGroupIri(value)) - Validation.fail(BadRequestException(IriErrorMessages.GroupIriInvalid)) - else if (isUuid && !UuidUtil.hasSupportedVersion(value)) - Validation.fail(BadRequestException(IriErrorMessages.UuidVersionInvalid)) - else - validateAndEscapeIri(value) - .mapError(_ => BadRequestException(IriErrorMessages.GroupIriInvalid)) - .map(new GroupIri(_) {}) - } - - def make(value: Option[String]): Validation[Throwable, Option[GroupIri]] = - value match { - case Some(v) => self.make(v).map(Some(_)) - case None => Validation.succeed(None) - } - } /** * ListIri value object. @@ -326,8 +295,6 @@ object Iri { } object IriErrorMessages { - val GroupIriMissing = "Group IRI cannot be empty." - val GroupIriInvalid = "Group IRI is invalid." val ListIriMissing = "List IRI cannot be empty." val ListIriInvalid = "List IRI is invalid" val ProjectIriMissing = "Project IRI cannot be empty." 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 ded490da6dd..a010c4c2922 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala @@ -7,7 +7,6 @@ package org.knora.webapi.messages import spray.json.* import zio.ZLayer -import zio.prelude.Validation import java.time.* import java.time.temporal.ChronoField @@ -624,9 +623,6 @@ class StringFormatter private ( private val ProjectIDPattern: String = """\p{XDigit}{4,4}""" - // A regex for matching a string containing the project ID. - private val ProjectIDRegex: Regex = ("^" + ProjectIDPattern + "$").r - // A regex for the URL path of an API v2 ontology (built-in or project-specific). private val ApiV2OntologyUrlPathRegex: Regex = ( "^" + "/ontology/((" + @@ -1525,16 +1521,6 @@ class StringFormatter private ( case _ => false } - /** - * Given the group IRI, checks if it is in a valid format. - * - * @param iri the group's IRI. - * @return the IRI of the list. - */ - def validateGroupIri(iri: IRI): Validation[ValidationException, IRI] = - if (Iri.isGroupIri(iri)) Validation.succeed(iri) - else Validation.fail(ValidationException(s"Invalid IRI: $iri")) - /** * Given the permission IRI, checks if it is in a valid format. * diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/groupsmessages/GroupsPayloadsADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/groupsmessages/GroupsPayloadsADM.scala index 634bc8c5e58..eb3cf51581c 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/groupsmessages/GroupsPayloadsADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/groupsmessages/GroupsPayloadsADM.scala @@ -6,7 +6,7 @@ package org.knora.webapi.messages.admin.responder.groupsmessages import dsp.valueobjects.Group.* -import dsp.valueobjects.Iri.* +import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri /** diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/permissionsmessages/PermissionsMessagesADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/permissionsmessages/PermissionsMessagesADM.scala index 1d4fd1b0d72..88d92107d64 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/permissionsmessages/PermissionsMessagesADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/permissionsmessages/PermissionsMessagesADM.scala @@ -23,6 +23,7 @@ import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsADMJso import org.knora.webapi.messages.admin.responder.usersmessages.UserADM import org.knora.webapi.messages.store.triplestoremessages.TriplestoreJsonProtocol import org.knora.webapi.messages.traits.Jsonable +import org.knora.webapi.slice.admin.domain.model.GroupIri ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // API requests @@ -55,7 +56,7 @@ case class CreateAdministrativePermissionAPIRequestADM( if (hasPermissions.isEmpty) throw BadRequestException("Permissions needs to be supplied.") if (!OntologyConstants.KnoraAdmin.BuiltInGroups.contains(forGroup)) { - sf.validateGroupIri(forGroup).getOrElse(throw BadRequestException(s"Invalid group IRI $forGroup")) + GroupIri.from(forGroup).getOrElse(throw BadRequestException(s"Invalid group IRI $forGroup")) } def prepareHasPermissions: CreateAdministrativePermissionAPIRequestADM = @@ -101,8 +102,7 @@ case class CreateDefaultObjectAccessPermissionAPIRequestADM( throw BadRequestException("Not allowed to supply groupIri and propertyIri together.") else { if (!OntologyConstants.KnoraAdmin.BuiltInGroups.contains(iri)) { - sf.validateGroupIri(iri) - .getOrElse(throw BadRequestException(s"Invalid group IRI ${forGroup.get}")) + GroupIri.from(iri).getOrElse(throw BadRequestException(s"Invalid group IRI ${forGroup.get}")) } } case None => diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/GroupsRouteADM.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/GroupsRouteADM.scala index 6910028b6ee..40a1f2eb548 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/admin/GroupsRouteADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/GroupsRouteADM.scala @@ -5,14 +5,15 @@ package org.knora.webapi.routing.admin -import org.apache.pekko +import org.apache.pekko.http.scaladsl.server.Directives.* +import org.apache.pekko.http.scaladsl.server.PathMatcher +import org.apache.pekko.http.scaladsl.server.Route import zio.* import zio.prelude.Validation import dsp.errors.BadRequestException import dsp.valueobjects.Group.* import dsp.valueobjects.Iri -import dsp.valueobjects.Iri.* import org.knora.webapi.core.MessageRelay import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.admin.responder.groupsmessages.* @@ -21,12 +22,9 @@ import org.knora.webapi.routing.KnoraRoute import org.knora.webapi.routing.KnoraRouteData import org.knora.webapi.routing.RouteUtilADM.* import org.knora.webapi.routing.RouteUtilZ +import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri -import pekko.http.scaladsl.server.Directives.* -import pekko.http.scaladsl.server.PathMatcher -import pekko.http.scaladsl.server.Route - /** * Provides a routing function for API routes that deal with groups. */ @@ -76,7 +74,9 @@ final case class GroupsRouteADM( private def createGroup(): Route = path(groupsBasePath) { post { entity(as[CreateGroupApiRequestADM]) { apiRequest => requestContext => - val id: Validation[Throwable, Option[GroupIri]] = GroupIri.make(apiRequest.id) + val id: Validation[Throwable, Option[GroupIri]] = apiRequest.id + .map(id => Validation.fromEither(GroupIri.from(id).map(Some(_))).mapError(BadRequestException(_))) + .getOrElse(Validation.succeed(None)) val name: Validation[Throwable, GroupName] = GroupName.make(apiRequest.name) val descriptions: Validation[Throwable, GroupDescriptions] = GroupDescriptions.make(apiRequest.descriptions) val project: Validation[Throwable, ProjectIri] = ProjectIri.from(apiRequest.project) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminPathVariables.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminPathVariables.scala index 8c52df63989..477bdf24985 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminPathVariables.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminPathVariables.scala @@ -12,14 +12,21 @@ import dsp.errors.BadRequestException import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.ShortcodeIdentifier import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.ShortnameIdentifier +import org.knora.webapi.slice.admin.domain.model.GroupIri object AdminPathVariables { + val groupIri: EndpointInput.PathCapture[GroupIri] = + path[GroupIri] + .name("groupIri") + .description("The IRI of a group. Must be URL-encoded.") + .example(GroupIri.unsafeFrom("http://rdfh.ch/groups/0001/qCSZzdAJCBqw_2snW5Q7NC")) + val projectIri: EndpointInput.PathCapture[IriIdentifier] = path[IriIdentifier] .name("projectIri") .description("The IRI of a project. Must be URL-encoded.") - .example(IriIdentifier.fromString("http://rdfh.ch/projects/0001").fold(e => throw e.head, identity)) + .example(IriIdentifier.unsafeFrom("http://rdfh.ch/projects/0001")) private val projectShortcodeCodec: Codec[String, ShortcodeIdentifier, TextPlain] = Codec.string.mapDecode(str => diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpoints.scala index 9b286e159ad..d0f7293cdf0 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpoints.scala @@ -15,6 +15,7 @@ import org.knora.webapi.messages.admin.responder.permissionsmessages.Administrat import org.knora.webapi.messages.admin.responder.permissionsmessages.AdministrativePermissionsForProjectGetResponseADM import org.knora.webapi.messages.admin.responder.permissionsmessages.CreateAdministrativePermissionAPIRequestADM import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionsADMJsonProtocol +import org.knora.webapi.slice.admin.api.AdminPathVariables.groupIri import org.knora.webapi.slice.admin.api.AdminPathVariables.projectIri import org.knora.webapi.slice.common.api.BaseEndpoints @@ -33,10 +34,8 @@ final case class PermissionsEndpoints(base: BaseEndpoints) extends PermissionsAD .description("Get all administrative permissions for a project.") .out(sprayJsonBody[AdministrativePermissionsForProjectGetResponseADM]) - private val groupIriPathVar = path[String].name("groupIri").description("The IRI of the group") - val getPermissionsApByProjectAndGroupIri = base.securedEndpoint.get - .in(permissionsBase / "ap" / projectIri / groupIriPathVar) + .in(permissionsBase / "ap" / projectIri / groupIri) .description("Get all administrative permissions for a project and a group.") .out(sprayJsonBody[AdministrativePermissionGetResponseADM]) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpointsHandlers.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpointsHandlers.scala index 1983837cdd2..b4a65b96382 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpointsHandlers.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpointsHandlers.scala @@ -13,6 +13,7 @@ import org.knora.webapi.messages.admin.responder.permissionsmessages.Administrat import org.knora.webapi.messages.admin.responder.permissionsmessages.CreateAdministrativePermissionAPIRequestADM import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier import org.knora.webapi.slice.admin.api.service.PermissionsRestService +import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.common.api.HandlerMapper import org.knora.webapi.slice.common.api.SecuredEndpointAndZioHandler @@ -46,11 +47,11 @@ final case class PermissionsEndpointsHandlers( private val getPermissionsApByProjectAndGroupIriHandler = SecuredEndpointAndZioHandler[ - (IriIdentifier, String), + (IriIdentifier, GroupIri), AdministrativePermissionGetResponseADM ]( permissionsEndpoints.getPermissionsApByProjectAndGroupIri, - user => { case (projectIri: IriIdentifier, groupIri: String) => + user => { case (projectIri: IriIdentifier, groupIri: GroupIri) => restService.getPermissionsApByProjectAndGroupIri(projectIri.value, groupIri, user) } ) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/PermissionsRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/PermissionsRestService.scala index ec867f046c9..fc2aef31407 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/PermissionsRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/PermissionsRestService.scala @@ -17,6 +17,7 @@ import org.knora.webapi.messages.admin.responder.permissionsmessages.Administrat import org.knora.webapi.messages.admin.responder.permissionsmessages.CreateAdministrativePermissionAPIRequestADM import org.knora.webapi.messages.admin.responder.usersmessages.UserADM import org.knora.webapi.responders.admin.PermissionsResponderADM +import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.admin.domain.model.KnoraProject import org.knora.webapi.slice.admin.domain.service.KnoraProjectRepo import org.knora.webapi.slice.common.api.AuthorizationRestService @@ -59,11 +60,11 @@ final case class PermissionsRestService( def getPermissionsApByProjectAndGroupIri( projectIri: KnoraProject.ProjectIri, - groupIri: String, + groupIri: GroupIri, user: UserADM ): Task[AdministrativePermissionGetResponseADM] = for { _ <- ensureProjectIriExistsAndUserHasAccess(projectIri, user) - result <- responder.getPermissionsApByProjectAndGroupIri(projectIri.value, groupIri) + result <- responder.getPermissionsApByProjectAndGroupIri(projectIri.value, groupIri.value) } yield result } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/GroupIri.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/GroupIri.scala new file mode 100644 index 00000000000..2a3f3a3e62a --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/GroupIri.scala @@ -0,0 +1,30 @@ +package org.knora.webapi.slice.admin.domain.model + +import sttp.tapir.Codec +import sttp.tapir.CodecFormat + +import dsp.valueobjects.Iri +import dsp.valueobjects.Iri.validateAndEscapeIri +import dsp.valueobjects.UuidUtil + +final case class GroupIri private (value: String) extends Iri + +object GroupIri { + + implicit val tapirCodec: Codec[String, GroupIri, CodecFormat.TextPlain] = + Codec.string.mapEither(GroupIri.from)(_.value) + + def unsafeFrom(value: String): GroupIri = from(value).fold(e => throw new IllegalArgumentException(e), identity) + + def from(value: String): Either[String, GroupIri] = + if (value.isEmpty) Left("Group IRI cannot be empty.") + else if (!(Iri.isIri(value) && value.startsWith("http://rdfh.ch/groups/"))) + Left("Group IRI is invalid.") + else if (UuidUtil.hasValidLength(value.split("/").last) && !UuidUtil.hasSupportedVersion(value)) + Left("Invalid UUID used to create IRI. Only versions 4 and 5 are supported.") + else + validateAndEscapeIri(value).toEither + .map(GroupIri.apply) + .left + .map(_ => "Group IRI is invalid.") +} diff --git a/webapi/src/test/scala/dsp/valueobjects/IriSpec.scala b/webapi/src/test/scala/dsp/valueobjects/IriSpec.scala index a574e87c4af..acacd55c31b 100644 --- a/webapi/src/test/scala/dsp/valueobjects/IriSpec.scala +++ b/webapi/src/test/scala/dsp/valueobjects/IriSpec.scala @@ -43,47 +43,7 @@ object IriSpec extends ZIOSpecDefault { val uuidVersion3 = fromIri(userIriWithUUIDVersion3) val supportedUuid = fromIri(validUserIri) - def spec: Spec[Any, Throwable] = groupIriTest + listIriTest + uuidTest + roleIriTest + userIriTest - - private val groupIriTest = suite("IriSpec - GroupIri")( - test("pass an empty value and return an error") { - assertTrue( - GroupIri.make("") == Validation.fail(BadRequestException(IriErrorMessages.GroupIriMissing)), - GroupIri.make(Some("")) == Validation.fail(BadRequestException(IriErrorMessages.GroupIriMissing)) - ) - }, - test("pass an invalid value and return an error") { - assertTrue( - GroupIri.make(invalidIri) == Validation.fail(BadRequestException(IriErrorMessages.GroupIriInvalid)), - GroupIri.make(Some(invalidIri)) == Validation.fail(BadRequestException(IriErrorMessages.GroupIriInvalid)) - ) - }, - test("pass an invalid IRI containing unsupported UUID version and return an error") { - assertTrue( - GroupIri.make(groupIriWithUUIDVersion3) == Validation.fail( - BadRequestException(IriErrorMessages.UuidVersionInvalid) - ), - GroupIri.make(Some(groupIriWithUUIDVersion3)) == Validation.fail( - BadRequestException(IriErrorMessages.UuidVersionInvalid) - ) - ) - }, - test("pass a valid value and successfully create value object") { - val groupIri = GroupIri.make(validGroupIri) - val maybeGroupIri = GroupIri.make(Some(validGroupIri)) - - (for { - iri <- groupIri - maybeIri <- maybeGroupIri - } yield assertTrue(iri.value == validGroupIri) && - assert(maybeIri)(isSome(equalTo(iri)))).toZIO - }, - test("successfully validate passing None") { - assertTrue( - GroupIri.make(None) == Validation.succeed(None) - ) - } - ) + def spec: Spec[Any, Throwable] = listIriTest + uuidTest + roleIriTest + userIriTest private val listIriTest = suite("IriSpec - ListIri")( test("pass an empty value and return an error") { diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/GroupIriSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/GroupIriSpec.scala new file mode 100644 index 00000000000..4379e364b37 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/GroupIriSpec.scala @@ -0,0 +1,30 @@ +package org.knora.webapi.slice.admin.domain.model + +import zio.test.Spec +import zio.test.ZIOSpecDefault +import zio.test.assertTrue + +import dsp.valueobjects.IriSpec.groupIriWithUUIDVersion3 +import dsp.valueobjects.IriSpec.invalidIri + +object GroupIriSpec extends ZIOSpecDefault { + val validGroupIri = "http://rdfh.ch/groups/0803/qBCJAdzZSCqC_2snW5Q7Nw" + + override val spec: Spec[Any, Nothing] = suite("GroupIri from should")( + test("pass an empty value and return an error") { + assertTrue(GroupIri.from("") == Left("Group IRI cannot be empty.")) + }, + test("pass an invalid value and return an error") { + assertTrue(GroupIri.from(invalidIri) == Left("Group IRI is invalid.")) + }, + test("pass an invalid IRI containing unsupported UUID version and return an error") { + assertTrue( + GroupIri.from(groupIriWithUUIDVersion3) == + Left("Invalid UUID used to create IRI. Only versions 4 and 5 are supported.") + ) + }, + test("pass a valid value and successfully create value object") { + assertTrue(GroupIri.from(validGroupIri).map(_.value) == Right(validGroupIri)) + } + ) +}